最近、DIALØGUEのiOSアプリにオフラインダウンロードをリリースしました。前回の記事で「しぶといオフライン」と手を振るように触れただけで、それが何を意味するのかは結局説明しなかった、あの部分です。この記事がその説明です。そして、それは僕がもう少しで間違ったものを作るところだった、という話から始まります。
正直に白状します。DIALØGUEはAIポッドキャストのアプリです。トピックかPDFを渡すと、二人のホストによる会話を生成してくれます。だからオフラインモードに取りかかったとき、僕の頭はまっすぐAIっぽい何かへ飛びました。たぶん「オフライン」というのは、端末の上で音声をあらかじめ生成しておくことなんじゃないか。モデルの出力をキャッシュするとか、ネットなしでエピソードを組み立て直せる賢いパイプラインとか。頭の中で、まるごとひとつのアーキテクチャが形になりかけていました。あやうく、その方向で設計ドキュメントを書くところでした。
そこで僕は立ち止まって、もっと地味な問いを立てました。これについて、iOSはもう何を用意してくれているんだろう?
答えは、ふたを開けてみれば「ほとんど全部」でした。そしてこの機能の本当の仕事は、賢い何かを作ることではありませんでした。その衝動に抗うことでした。
あの地味な問いが、僕の一か月を救った
これは記事を書くたびに学び直していることです。複雑さは、あなたの味方ではありません。 とくに、夜と週末に一人で作っているときには。
AIポッドキャストは、いったん生成されてしまえば、URLの上に置かれたただの音声ファイルです。「オフライン」とは、それを再生成することではありません。そのファイルをダウンロードして、あとで再生することです。これはAIの問題ではありません。App Storeが存在するよりも前から、Appleがずっと解いてきた問題です。
だから賢いパイプラインの代わりに、僕は道具箱の中でいちばん地味で、いちばん使い込まれた道具に手を伸ばしました。background URLSession です。もし出会ったことがなければ、考え方はシンプルです——ダウンロードをOSに手渡すと、アプリがバックグラウンドにいても、あるいは終了させられていても、OSが処理を続けてくれて、終わったら改めてアプリを起動し直して、完成したファイルを渡してくれます。
let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true
だいたい二行。これで、本当に難しい部分はOSが持ってくれます。僕はダウンロードエンジンを書きませんでした。Appleのものを借りたんです。
同じ話が、見るところすべてで繰り返されました。
- ダウンロードが途中で切れた? iOSは「resume data」を渡してくれます——ダウンロードをゼロからやり直すのではなく、止まったところから再開できる、小さなデータの塊です。僕はそれをディスクに書き出して(捨ててかまわないものなので
Cachesフォルダに)、次のときに渡し直すだけです。 - ユーザーのモバイル通信を食いつぶしたくない? 一行です。
request.allowsCellularAccess = !wifiOnly。あとはOSが自分でWiFiを待ちます。僕は接続を監視しません。何もポーリングしません。 - ダウンロードがどれくらい容量を使っているか表示したい?
ByteCountFormatterが、バイト数を、正しくローカライズされた単位つきの「23.4 MB」に変えてくれます。これも僕が書いたものではありません。
これがどれだけ気持ちのいいことか、正直に書いておきたいんです。直感には逆らうことだからです。振り返ってみると、正しい一手は、もっと賢いコードではありませんでした。これは解決済みの問題だと認めて、自分の知恵で上回ろうとするのではなく、その仕事を借りることでした。
でも「地味」は「簡単」という意味ではなかった
ここで、ひとつ白状しなければなりません。標準的なやり方のおかげでこれが簡単になった、なんて言ったら、それは嘘です——そしてこれを読んでいるiOSエンジニアなら、十秒くらいで僕を見抜くでしょう。
標準であることは、簡単であることとは違います。地味な道具が地味なのは、よく使い込まれているからであって、摩擦がないからではありません。実際に僕の時間を食った事柄を挙げておきます。このリストがあなたの時間を少しでも節約できればと思って。
1. 一時ファイルは、見ているそばから消える。 バックグラウンドのダウンロードが終わると、iOSはコールバックを呼んで、一時的な場所にあるファイルを指し示してくれます。誰も言わない落とし穴:そのファイルは、あなたのコールバックが return した瞬間に削除されます。何か他のことをする前に、その関数の中でその場で、同期的に それを永続的な場所へ移さなければなりません。さらに厄介なのは、そのコールバックがメインスレッドの外で走っていて、終わったのが どのダウンロード かは分かっても、それが僕のアプリの言葉で どのエピソード なのかは分からない、ということです。だからコードは、不格好な二段ステップを踏みます——まずファイルをすぐに一時的な名前に移し、それが何なのか分かったらメインスレッドへ飛んで、名前を付け替える。きれいではありません。でも、もう一方の道は、保存する前に消えてしまったファイルです。
2. シミュレータでは、バックグラウンドセッションは黙って何もしない。 僕は、自分のコードが壊れていると思い込んで、恥ずかしいくらい長い時間を費やしました。壊れてなどいませんでした。background URLSession は、iOSシミュレータ上では、ただ単に当てにならない動きをするのです——エラーもなし、クラッシュもなし、何もダウンロードされない。直し方は不格好で、それは僕の責任です。
#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 はエレガントではありません。でも、シミュレータが実機と同じように振る舞うふりをするほうが、そうではないと認めることよりも、はるかに高くついたはずです。
3. Swift 6は、誰がどのスレッドで走るのかを、僕にきっちり言わせた。 ダウンロードのコールバックはバックグラウンドスレッドにやってきます。僕のUIの状態——あの小さな進捗リング、緑の「ダウンロード済み」のチェックマーク——は、メインスレッドで更新されなければなりません。Swift 6の、より厳しい並行性チェックは、その境界で僕がいい加減でいることを許してくれませんでした。最終的に、文字どおり 「resolve Swift 6 actor-isolation warnings」 というタイトルのクリーンアップコミットが残りました。その場では煩わしく、振り返れば正しい。コンパイラが正しくて、僕が間違っていました。
4. サーバーはいつもファイルにきれいな名前を付けてくれるわけではない。 音声は、audio/mpeg で返ってくることもあれば、m4a のこともあり、たまに別の何かのこともあります。だから、レスポンスのcontent typeからファイルの拡張子を割り出し、だめならURLにフォールバックし、最後は .mp3 にする、という小さくて地味な関数があります。華はありません。でも必要です。
これらのどれも、AIの問題ではありません。どれもこれも「コンピュータが実際にどう動くか」の問題です。それが地味な道を行くときの税金です。それは本物です——でも僕は、自分で発明した賢い何かを保守し続けるコストを払うくらいなら、毎回この税金のほうを払います。
僕が意図的に作らなかったもの
この機能まるごとの中でいちばん役に立つ部分は、僕が やらなかった ことのリストだと思っています。
- ダウンロードエンジンは書きませんでした。(background
URLSession。) - 接続状態の監視は書きませんでした。(
allowsCellularAccess。) - 再開プロトコルは発明しませんでした。(OSの resume data。)
- AIに特化した何かは作りませんでした。(ただのファイルです。ダウンロードされるだけ。)
僕が 本当に 気を配ったのは、小さくて華のないことでした。同時に三つを超えるダウンロードが走らず、残りは順番を待つようにする小さなキュー。コールド起動のあとにストレージ画面が「エピソード、エピソード、エピソード」ではなく本当のタイトルを出すように、エピソード名を保存しておくこと。空き容量を作ろうとしているときにいちばん重い犯人が上に来るように、その画面をファイルの大きい順に並べること。そして、本当の判断——空きスロットがいくつあるか、リストをどう並べるか——を、URLSession を一切くっつけていないただの関数として外に取り出しました。きちんとユニットテストできるように。地味です。テストできます。テストできる からこそ 地味なんです。
その教訓、たぶん百回目くらい
僕はもう、この記事のいくつものバージョンを書いてきました。自分のウェブサイトを作り直した話、54ドルのAPI請求の話、まさにこのアプリを作り直した話。教訓はいつも同じで、そして僕はそれを何度も学び直す必要があります。賢い解決策は、たいてい高くつく解決策だ。「プラットフォームはもう何を用意してくれているんだろう?」——これが、一行のコードを書く前に僕が立てられる、いちばん価値のある問いだ。
とくに一人で作る人にとっては、こうです。あなたが発明する賢いものは一つひとつ、それが最悪のタイミングで壊れたとき、あなた一人で保守しなければならないものです。あなたが借りる地味で標準的なものは一つひとつ、何千人ものエンジニアを背負った会社が保守してくれているものです。僕の経験からすると、その取引はほとんどいつもする価値があります——そして正直なところ、その取引をする規律のほうが、それが置き換える賢さよりも難しい。とはいえ、その線が正確にどこにあるのかについては、僕は間違っているかもしれません。プラットフォームが何もくれず、難しい部分を自分で作らなければならない問題も、きっとあります——ただ、それは僕の直感が言い張るよりも、ずっと稀なことだと、何度も思い知らされているだけです。
DIALØGUEを使ってくれている何人かは、いまではエピソードをダウンロードして、飛行機に乗って、電波なしで聴くことができます。意味のある機能だと感じます。その裏側のコードは、ほとんど攻撃的なくらい、なんの変哲もありません。僕はそのことと折り合いをつけました。それこそが仕事なんだと思います。
あなたもこれをやりませんか——地味なほうがすぐそこにあったのに、つい賢いほうへ手を伸ばしている自分に気づく、ということを。それとも、同じ教訓を何度も何度も学び直しているのは、僕が遅いだけなんでしょうか。ほかの人がこの直感とどう闘っているのか、本当に聞いてみたいんです。
それでは、 Chandler