Belakangan saya baru saja merilis fitur unduh episode untuk didengarkan offline di app iOS DIALØGUE — persis bagian yang di tulisan sebelumnya hanya saya sebut sekilas sebagai "offline yang tangguh" tanpa pernah menjelaskan apa maksudnya. Tulisan ini adalah penjelasannya. Dan ceritanya dimulai dari saya yang nyaris membangun hal yang salah.
Ini pengakuannya. DIALØGUE adalah app podcast AI: kamu beri sebuah topik atau file PDF, lalu app-nya menghasilkan percakapan antara dua pembawa acara. Jadi waktu mulai menggarap mode offline, otak saya langsung melompat ke sesuatu yang berbau AI. Mungkin "offline" berarti membuat audio terlebih dahulu langsung di perangkat. Mungkin meng-cache output model, atau semacam pipeline pintar yang bisa menyusun ulang sebuah episode tanpa jaringan. Sebuah arsitektur utuh sudah mulai terbentuk di kepala saya. Saya nyaris menulis design doc-nya dengan cara itu.
Lalu saya berhenti dan bertanya hal yang lebih membosankan: apa yang sudah diberikan iOS untuk hal ini?
Ternyata jawabannya adalah "hampir semuanya." Dan pekerjaan tersulit dari fitur ini bukanlah membangun sesuatu yang pintar. Melainkan menahan dorongan untuk melakukannya.
Pertanyaan membosankan itu menghemat satu bulan saya
Inilah hal yang terus saya pelajari berulang kali, tulisan demi tulisan: kompleksitas bukan temanmu. Apalagi kalau kamu cuma sendirian, membangun di malam hari dan akhir pekan.
Sebuah podcast AI, begitu sudah dihasilkan, hanyalah sebuah file audio yang ada di sebuah URL. "Offline" bukan berarti membuatnya lagi. Artinya mengunduh file itu lalu memutarnya nanti. Itu bukan masalah AI. Itu masalah yang sudah Apple selesaikan bahkan sebelum App Store ada.
Jadi alih-alih sebuah pipeline pintar, saya meraih perkakas paling membosankan dan paling teruji di dalam kotak: sebuah background URLSession. Kalau kamu belum pernah berkenalan dengannya, idenya sederhana — kamu serahkan proses unduh ke sistem operasi, dan ia terus berjalan bahkan ketika app kamu ada di background atau sudah ditutup paksa, lalu ia membuka kembali app kamu untuk menyerahkan file yang sudah selesai.
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
Kira-kira dua baris, dan sekarang sistem operasi yang memegang bagian-bagian yang benar-benar sulit. Saya tidak menulis sebuah download engine. Saya meminjam milik Apple.
Cerita yang sama terulang di mana pun saya melihat:
- Unduhan terputus di tengah jalan? iOS memberi kamu "resume data" — sebongkah blob kecil yang membuat unduhan bisa melanjutkan dari titik terakhir, bukan mulai lagi dari nol. Saya tinggal menuliskannya ke disk (di folder
Caches, karena ini memang bisa dibuang) lalu memberikannya kembali di lain waktu. - Tidak mau menghabiskan kuota seluler pengguna? Satu baris:
request.allowsCellularAccess = !wifiOnly. Setelah itu sistem operasi sendiri yang menunggu WiFi. Saya tidak memantau koneksi. Saya tidak mem-poll apa pun. - Perlu menampilkan berapa banyak ruang yang dipakai unduhan?
ByteCountFormattermengubah hitungan byte menjadi "23,4 MB" dengan satuan yang sudah dilokalkan dengan benar. Yang itu pun bukan saya yang menulis.
Saya ingin jujur soal betapa enaknya rasanya, karena ini justru melawan insting. Kalau dipikir-pikir, langkah yang benar bukanlah kode yang lebih pintar. Melainkan mengakui bahwa ini adalah masalah yang sudah terpecahkan dan meminjam hasil kerja orang lain alih-alih mencoba mengakalinya sendiri.
Tapi "membosankan" bukan berarti "gampang"
Sekarang saya harus mengakui sesuatu, karena kalau saya bilang praktik standar membuat ini jadi sepele, saya berbohong — dan developer iOS mana pun yang membaca tulisan ini akan menangkap basah saya dalam waktu sekitar sepuluh detik.
Standar tidak sama dengan sepele. Perkakas yang membosankan itu membosankan karena sudah usang dipakai, bukan karena tanpa hambatan. Inilah hal-hal yang benar-benar menyita waktu saya, kalau-kalau daftar ini bisa menghemat waktu kamu:
1. File sementaranya hilang tepat di depan mata. Ketika unduhan background selesai, iOS memanggil kamu kembali dan menunjuk ke file di sebuah lokasi sementara. Jebakan yang tidak disebut siapa pun: file itu dihapus pada detik callback kamu selesai. Kamu harus memindahkannya ke tempat permanen secara sinkron, di situ juga di dalam fungsinya, sebelum melakukan hal lain apa pun. Lebih canggung lagi, karena callback itu berjalan di luar main thread dan hanya tahu unduhan mana yang selesai, bukan episode mana dalam istilah app saya. Jadi kodenya melakukan gerakan dua langkah yang jelek: pindahkan file ke nama sementara secepatnya, lalu lompat ke main thread untuk menamainya ulang begitu sudah tahu itu apa. Tidak cantik. Tapi alternatifnya adalah file yang sudah lenyap sebelum sempat kamu simpan.
2. Background session diam-diam tidak melakukan apa-apa di simulator. Saya menghabiskan rentang waktu yang memalukan dengan yakin bahwa kode saya rusak. Padahal tidak. Background URLSession memang tidak berperilaku andal di iOS Simulator — tanpa error, tanpa crash, tidak ada yang terunduh. Perbaikannya jelek dan saya akui itu:
#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
#if DEBUG itu tidak elegan. Tapi berpura-pura simulator berperilaku seperti perangkat asli akan jauh lebih mahal harganya buat saya ketimbang mengakui bahwa ia tidak begitu.
3. Swift 6 memaksa saya menyatakan dengan tepat siapa berjalan di thread mana. Callback unduhan datang di sebuah background thread. State UI saya — lingkaran-lingkaran progress kecil, centang hijau "Terunduh" — harus diperbarui di main thread. Pengecekan concurrency Swift 6 yang lebih ketat menolak membiarkan saya ceroboh di batas itu. Saya berakhir dengan sebuah commit pembersihan yang judulnya secara harfiah "resolve Swift 6 actor-isolation warnings." Menjengkelkan saat itu, benar kalau dilihat belakangan. Compiler-nya benar dan saya yang salah.
4. Server tidak selalu memberi nama file dengan rapi. Audio kadang kembali sebagai audio/mpeg, kadang sebagai m4a, sesekali sesuatu yang lain. Jadi ada sebuah fungsi kecil yang membosankan yang bertugas menyimpulkan ekstensi file dari content type pada response, mundur ke URL kalau gagal, dan default ke .mp3. Tidak glamor. Tapi perlu.
Tidak satu pun dari ini adalah masalah AI. Setiap satunya adalah masalah "bagaimana komputer sebenarnya bekerja". Itulah pajak dari jalan yang membosankan. Pajak itu nyata — tapi saya akan membayarnya setiap kali, ketimbang harus menanggung biaya merawat sesuatu yang pintar yang saya ciptakan sendiri.
Yang sengaja TIDAK saya bangun
Saya rasa bagian paling berguna dari seluruh fitur ini justru adalah daftar hal-hal yang tidak saya kerjakan:
- Saya tidak menulis sebuah download engine. (Background
URLSession.) - Saya tidak menulis sebuah pemantau koneksi. (
allowsCellularAccess.) - Saya tidak menciptakan sebuah protokol resume. (Resume data dari sistem operasi.)
- Saya tidak membangun apa pun yang khusus-AI. (Ini sebuah file. Ia terunduh.)
Yang benar-benar saya curahkan perhatian justru kecil dan tidak glamor: sebuah antrean mungil supaya tidak lebih dari tiga unduhan berjalan sekaligus dan sisanya menunggu giliran; menyimpan judul episode supaya layar penyimpanan menampilkan nama asli setelah cold launch, bukan "Episode, Episode, Episode"; mengurutkan layar itu dari file terbesar lebih dulu, supaya ketika kamu sedang berusaha melegakan ruang, biang keladi yang paling berat duduk di paling atas. Dan saya tarik keputusan-keputusan yang sesungguhnya — berapa slot yang kosong, bagaimana mengurutkan daftarnya — keluar menjadi fungsi-fungsi biasa tanpa URLSession sama sekali, supaya bisa saya unit-test dengan benar. Membosankan. Bisa dites. Membosankan justru karena bisa dites.
Pelajarannya, untuk yang kira-kira keseratus kalinya
Sampai sekarang saya sudah menulis versi tulisan ini soal membangun ulang website saya, soal tagihan API 54 dolar, soal membangun ulang app yang ini juga. Pelajarannya selalu sama, dan saya terus perlu mempelajarinya lagi: solusi pintar biasanya solusi yang mahal. "Apa yang sudah diberikan platform untuk saya?" — itulah pertanyaan paling berharga yang bisa saya ajukan sebelum menulis satu baris kode pun.
Khususnya buat solo builder: setiap hal pintar yang kamu ciptakan adalah hal yang harus kamu rawat sendirian ketika ia rusak di saat yang paling buruk. Setiap hal membosankan dan standar yang kamu pinjam dirawat oleh sebuah perusahaan dengan ribuan engineer di belakangnya. Dari pengalaman saya, pertukaran itu hampir selalu sepadan — dan jujur saja, disiplin untuk melakukannya lebih sulit daripada kepintaran yang ia gantikan. Meski begitu, saya bisa saja keliru soal di mana persisnya garis batasnya. Pasti ada masalah-masalah di mana platform tidak memberi kamu apa-apa dan kamu harus membangun sendiri bagian yang sulit — saya cuma terus mendapati bahwa itu lebih jarang daripada yang terus diyakini insting saya.
Beberapa orang yang memakai DIALØGUE sekarang bisa mengunduh sebuah episode, naik pesawat, dan mendengarkannya tanpa sinyal. Rasanya ini fitur yang berarti. Sementara kode di baliknya nyaris biasa-biasa saja dengan tegas. Saya sudah berdamai dengan itu. Saya rasa memang itulah pekerjaannya.
Kamu juga begini, nggak — mendapati diri sendiri meraih versi yang pintar padahal yang membosankan ada tepat di situ? Atau memang saya saja yang lambat belajar, mengulang pelajaran yang sama berkali-kali? Saya sungguh ingin mendengar bagaimana orang lain melawan insting yang satu ini.
Salam hangat, Chandler