Gần đây mình vừa ship tính năng tải tập về để nghe offline cho app iOS của DIALØGUE — đúng cái phần mà ở bài trước mình chỉ vẫy tay gọi là "offline bền bỉ" mà chẳng bao giờ giải thích nó nghĩa là gì. Bài này là phần giải thích đó. Và nó bắt đầu bằng chuyện mình suýt nữa xây nhầm thứ.
Thú thật luôn nhé. DIALØGUE là một app podcast AI: bạn đưa cho nó một chủ đề hoặc một file PDF, và nó tạo ra một cuộc trò chuyện giữa hai người dẫn. Nên khi bắt tay vào làm chế độ offline, đầu mình lập tức nghĩ tới một thứ gì đó mang dáng dấp AI. Biết đâu "offline" nghĩa là tạo sẵn audio ngay trên máy. Biết đâu là cache lại output của model, hay một pipeline thông minh nào đó có thể dựng lại một tập mà không cần mạng. Cả một kiến trúc đang dần hình thành trong đầu mình. Mình suýt nữa đã viết tài liệu thiết kế theo đúng hướng đó.
Rồi mình dừng lại và đặt một câu hỏi nhàm chán hơn: iOS đã cho mình sẵn những gì để làm việc này?
Hoá ra câu trả lời là "gần như mọi thứ." Và việc khó nhất của tính năng này không phải là xây một thứ gì đó thông minh. Mà là kìm lại cái ham muốn làm thế.
Câu hỏi nhàm chán giúp mình tiết kiệm cả một tháng
Đây là điều mình cứ phải học đi học lại, bài này qua bài khác: sự phức tạp không phải là bạn của bạn. Nhất là khi bạn chỉ có một mình, làm vào buổi tối và cuối tuần.
Một podcast AI, một khi đã được tạo ra, thì chỉ là một file audio nằm ở một cái URL. "Offline" không có nghĩa là tạo lại nó. Nó có nghĩa là tải file đó về rồi phát lại sau. Đó không phải là một bài toán AI. Đó là bài toán mà Apple đã giải từ trước cả khi có App Store.
Nên thay vì một pipeline thông minh, mình với tay lấy công cụ nhàm chán và lì lợm nhất trong hộp đồ nghề: một background URLSession. Nếu bạn chưa từng gặp nó, ý tưởng rất đơn giản — bạn giao việc tải về cho hệ điều hành, và nó cứ tiếp tục chạy ngay cả khi app của bạn đang ở chế độ nền hoặc đã bị tắt, rồi nó khởi động lại app của bạn để trao cho bạn file đã tải xong.
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
Khoảng hai dòng, và giờ hệ điều hành nắm giữ những phần thực sự khó. Mình không viết một download engine. Mình mượn của Apple.
Câu chuyện đó lặp lại ở mọi chỗ mình nhìn vào:
- Tải đang dở thì bị ngắt? iOS đưa cho bạn "resume data" — một blob nhỏ cho phép việc tải tiếp tục từ chỗ dừng lại thay vì bắt đầu lại từ con số không. Mình chỉ việc ghi nó xuống đĩa (trong thư mục
Caches, vì nó là thứ có thể vứt đi) rồi đưa lại cho lần sau. - Không muốn ngốn hết dung lượng data 4G của người dùng? Một dòng:
request.allowsCellularAccess = !wifiOnly. Sau đó hệ điều hành tự nó chờ WiFi. Mình không theo dõi kết nối. Mình không poll bất cứ thứ gì. - Cần hiển thị downloads đang chiếm bao nhiêu dung lượng?
ByteCountFormatterbiến một con số byte thành "23,4 MB" với đúng đơn vị đã được bản địa hoá. Cái đó mình cũng không viết.
Mình muốn thành thật về chuyện cảm giác này dễ chịu đến mức nào, vì nó đi ngược lại bản năng. Nhìn lại, nước đi đúng đắn không phải là code khôn ngoan hơn. Mà là thừa nhận đây là một bài toán đã có lời giải và mượn lại công sức của người khác thay vì cố vắt óc nghĩ ra hay hơn họ.
Nhưng "nhàm chán" không có nghĩa là "dễ"
Giờ mình phải thú nhận một điều, vì nếu mình bảo bạn rằng những cách làm tiêu chuẩn khiến chuyện này trở nên đơn giản, thì mình nói dối — và bất kỳ lập trình viên iOS nào đọc bài này sẽ bắt thóp mình trong khoảng mười giây.
Tiêu chuẩn không đồng nghĩa với đơn giản. Mấy công cụ nhàm chán nhàm chán vì chúng đã quá quen tay, chứ không phải vì chúng trơn tru không vướng víu. Đây là những thứ thực sự ngốn thời gian của mình, biết đâu cái danh sách này tiết kiệm cho bạn được chút ít:
1. File tạm biến mất ngay trước mắt bạn. Khi một lượt tải nền hoàn tất, iOS gọi lại cho bạn và trỏ tới file ở một vị trí tạm. Cái bẫy không ai nhắc tới: file đó bị xoá ngay khoảnh khắc cái callback của bạn kết thúc. Bạn phải chuyển nó tới một nơi cố định một cách đồng bộ, ngay tại chỗ trong hàm đó, trước khi làm bất cứ việc gì khác. Còn vướng hơn nữa, vì cái callback đó chạy ngoài main thread và chỉ biết lượt tải nào vừa xong, chứ không biết tập nào trong app của mình. Nên đoạn code phải làm một bước nhảy hai nhịp xấu xí: chuyển file sang một cái tên tạm ngay lập tức, rồi nhảy về main thread để đổi tên nó một khi đã biết nó là gì. Không đẹp. Nhưng lựa chọn còn lại là một file biến mất trước khi bạn kịp lưu.
2. Background session âm thầm chẳng làm gì trên simulator. Mình đã trải qua một quãng thời gian xấu hổ, đinh ninh rằng code của mình bị hỏng. Không hề. Background URLSession đơn giản là chạy không đáng tin trên iOS Simulator — không lỗi, không crash, chẳng tải về gì cả. Cách sửa thì xấu, và mình nhận đó là phần của mình:
#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif
Cái #if DEBUG đó không hề tao nhã. Nhưng giả vờ rằng simulator hành xử y như máy thật sẽ khiến mình trả giá đắt hơn nhiều so với việc thừa nhận là nó không như vậy.
3. Swift 6 bắt mình nói rõ chính xác ai chạy trên thread nào. Các callback của lượt tải đến trên một background thread. Phần UI state của mình — mấy cái vòng tròn tiến trình, dấu tick xanh "Đã tải" — thì phải cập nhật trên main thread. Cơ chế kiểm tra concurrency nghiêm ngặt hơn của Swift 6 không cho phép mình cẩu thả ở cái ranh giới đó. Mình kết thúc với một commit dọn dẹp có tiêu đề đúng nguyên văn là "resolve Swift 6 actor-isolation warnings." Bực mình lúc đó, nhưng đúng đắn khi nhìn lại. Trình biên dịch đúng còn mình thì sai.
4. Server không phải lúc nào cũng đặt tên file cho gọn gàng. Audio trả về lúc thì là audio/mpeg, lúc thì là m4a, thi thoảng là thứ gì đó khác nữa. Nên có một hàm nhỏ, buồn tẻ, làm nhiệm vụ suy ra phần đuôi file từ content type của response, không có thì lùi về dùng URL, và mặc định là .mp3. Chẳng hào nhoáng. Nhưng cần thiết.
Không cái nào trong số này là bài toán AI. Mỗi cái đều là bài toán "máy tính thực ra vận hành thế nào". Đó là cái thuế của con đường nhàm chán. Nó có thật — nhưng mình sẵn sàng trả nó mọi lần, còn hơn cái giá phải bảo trì một thứ thông minh do chính mình nghĩ ra.
Những thứ mình cố tình KHÔNG xây
Mình nghĩ phần hữu ích nhất của cả tính năng này lại là cái danh sách những việc mình đã không làm:
- Mình không viết một download engine. (Background
URLSession.) - Mình không viết một bộ theo dõi kết nối. (
allowsCellularAccess.) - Mình không phát minh ra một giao thức resume. (Resume data của hệ điều hành.)
- Mình không xây bất cứ thứ gì kiểu chỉ-dành-cho-AI. (Nó là một file. Nó được tải về.)
Cái mà mình thực sự dồn công sức vào thì nhỏ bé và chẳng hào nhoáng: một cái hàng đợi tí hon để không quá ba lượt tải chạy cùng lúc và phần còn lại chờ tới lượt; lưu lại tên các tập để màn hình quản lý dung lượng hiện ra tên thật sau một lần khởi động nguội, thay vì "Tập, Tập, Tập"; sắp xếp màn hình đó theo file lớn nhất trước, để khi bạn đang cố giải phóng dung lượng thì mấy thủ phạm nặng kí nằm ngay trên cùng. Và mình tách những quyết định thật sự — còn mấy slot trống, sắp xếp danh sách ra sao — thành những hàm thuần tuý không dính dáng gì tới URLSession, để có thể unit-test chúng cho đàng hoàng. Nhàm chán. Test được. Nhàm chán bởi vì test được.
Bài học, lần thứ khoảng một trăm
Tới giờ mình đã viết một phiên bản nào đó của bài này về chuyện dựng lại website của mình, về một hoá đơn API 54 đô, về chuyện dựng lại chính cái app này. Bài học cứ luôn là một, và mình cứ phải học lại nó: giải pháp thông minh thường là giải pháp đắt đỏ. "Nền tảng đã cho mình sẵn những gì?" — đó là câu hỏi giá trị nhất mình có thể đặt ra trước khi viết một dòng code.
Với một người làm một mình thì lại càng đúng: mỗi thứ thông minh bạn phát minh ra là một thứ mà chỉ mình bạn phải bảo trì khi nó hỏng vào đúng thời điểm tệ nhất. Mỗi thứ nhàm chán, tiêu chuẩn mà bạn mượn lại thì được bảo trì bởi một công ty với hàng nghìn kỹ sư đứng sau. Từ kinh nghiệm của mình, cái đánh đổi đó gần như luôn đáng để làm — và thành thật mà nói, cái kỷ luật để làm điều đó còn khó hơn chính sự thông minh mà nó thay thế. Dù vậy, mình có thể sai về chuyện cái lằn ranh đó nằm chính xác ở đâu. Chắc chắn có những bài toán mà nền tảng chẳng cho bạn gì cả và bạn buộc phải tự xây cái phần khó — mình chỉ cứ thấy rằng chuyện đó hiếm hơn cái bản năng của mình vẫn khăng khăng.
Một vài người đang dùng DIALØGUE giờ có thể tải một tập về, lên máy bay, và nghe mà không cần sóng. Cảm giác nó là một tính năng có ý nghĩa. Còn đoạn code đằng sau nó thì gần như tầm thường một cách quyết liệt. Mình đã làm hoà với điều đó. Mình nghĩ đó mới là công việc.
Bạn có hay làm thế này không — bắt gặp chính mình đang với tay tới phiên bản thông minh trong khi cái nhàm chán nằm ngay đó? Hay chỉ mình mình là chậm hiểu, cứ học đi học lại đúng một bài học? Mình thật lòng muốn nghe xem người khác chống lại cái bản năng này như thế nào.
Thân mến, Chandler