Gadeninアプリを入れる

プロダクト読了 10 分

Expo でアップロードのプログレスバーを出す方法。合計 MB で正しく進める

写真や動画をつけた記録の保存中に、本当に送れているのか分からなかった。expo-file-system の送信済みバイト数を1か所に集め、合計 MB に対する本物のプログレスバーにした。1.8.2 の実装を図4枚で書く。

Gadeninアプリにおいてのコア機能である写真・動画のアップロード機能。 原稿用紙1枚のテキストデータと比較して、Gadeninで画像と動画を同時アップする際のデータ量は30万倍ほど大きなサイズとなる。 現代の圧縮技術を持ってしてもそれくらい巨大なバイナリデータをアップするわけなので、ユーザーには待ち時間を強いてしまう。

僕自身が家庭菜園の記録アプリとして自分が開発したGadeninを使っていたとしても、アップロードの待ち時間が10秒を超えるとストレスだ。

待つだけならまだ良いのだが、当初の実装はそれだけの待ち時間を考慮していなかったこともあり、アップロード最中は「アップロード中」というローディングアイコンみたいなものをGifアニメで表示していたが、それにしてもそれが長すぎると、ちゃんとアップされているのか心配になる。

これが大問題で、動画となると20秒とか待つことも稀にあった。ちゃんと動いているのか不安になり、途中でアプリを強制終了して確認したい気持ちをぐっと抑えて利用せざるをえなかった。

これはよくあるプログレスバーを用いて、リアルタイムにアップロードが完了したデータサイズ量を表示することで、ちゃんとアップロード作業自体がうまく行っていることをユーザーに見せることで、安心して待つことができるようになるはずだ。

そう思い、早速そこの実装にはいる。

Claude Code にはこう送った。 「アップロード中のローディングが長すぎて本当にアップロードできているのか心配になる。トータルのアップロードサイズから現時点でのアップロード完了のプログレスバーを表示したりできる?技術的な仕様でできない?」

少しの微修正指示を送っただけであっさりできた。いまは合計 MB に対して何 MB 送ったかが、ファイルごとの区切りつきで出る。Gadenin 1.8.2 として審査に出したところだ。 書くのは、数字をどこから取ったかと、実機で一度壊れた理由と、バーの見た目の決め方の3つ。

実際の保存中の画面を4枚並べたもの。34 MB の動画1本を送っている。左から 5.2 / 34 MB で15%、15 / 34 MB で43%、29 / 34 MB で85%、34 / 34 MB で「仕上げています」。どの画面も上に「0 / 1 件」、下に割合、バーの先端に緑の葉が乗っている

届くのは送信済みバイト数だけだった

アップロードの進捗は、OS が知っている。問題はそれが ReactNative の JS まで届くかだった。

Gadenin のアップロードは、もともと expo-file-system の uploadAsync で送っている。ファイルをディスクから直接送るので、中身が JS のスレッドを通らない。大きな写真でも画面が止まらない。 そしてダメ押しとして、uploadAsync は終わるまで何も言わない。

一方で、同じ expo-file-system に createUploadTask がある。こちらは送信中に、送ったバイト数と送る予定のバイト数を何度も知らせてくる。 差し替えたのは、この数行だった。

// 進捗が要るときは createUploadTask (送信済みバイト数が届く)。無いときは今までどおり uploadAsync
const res = onProgress
  ? await FileSystem.createUploadTask(`${this.baseUrl}${path}`, fileUri, options, (e: { totalBytesSent: number; totalBytesExpectedToSend: number }) => {
    onProgress(e.totalBytesSent, e.totalBytesExpectedToSend);
  }).uploadAsync()
  : await FileSystem.uploadAsync(`${this.baseUrl}${path}`, fileUri, options);

fetch に Uint8Array を載せて送れば、進み具合は自分で数えられる。ただ、それだとファイルの中身が JS を通る。uploadAsync にした意味がなくなるので、選ばなかった。

で、ここで分かるのは「いま送っている1件の進み具合」だけだ。 保存ボタンを押したときに見せたいのは、全部で何 MB のうち何 MB か。そのためには、送り始める前に全部の大きさを知っている必要がある。

図1。上に3つの箱が左から右へ並ぶ。1「全部を測る」prepare()、写真は JPEG にしてから測る。2「1件ずつ送る」save()、写真も動画も同じ入口。3「端末が送る」createUploadTask、中身は JS を通らない。それぞれから下の黄色い「進捗のストア uploadProgress」へ矢印が下りる。1からは「合計 MB」で begin(files)、2からは「1件終わった」で done(key)、3からは「数十 KB ごと」で report(sent)。ストアには「描き直しは 100ms に1回」とある。ストアから useSyncExternalStore で、下の緑の「保存中の画面」(15 / 34 MB とファイルごとに区切られたバー) へ矢印が下りる

送る前に、全部の大きさを測る

写真の大きさは、選んだ時点では決まっていない。

Gadenin は写真を送る前に JPEG に描き直している。とにかく個人情報をなるべくリスクなくとりあつかうために全神経を集中して設計しているので、当然のことながら位置情報を含む Exif を落とすため、元のファイルは絶対に送らない。JPEG 変換後のファイルサイズは、実際に書き出してみないとわからない。

そこで、JPEG 変換プロセスを先に行うフローにすることにした。保存ボタンを押したら、まず全部を描き直して大きさを測る。このとき、JPEG 変換したファイルは覚えておき、送るときにはそれを使う。

async prepare(assetUris: string[]): Promise<void> {
  this.prepared.clear();
  if (assetUris.length === 0) return;
  const files: { key: string; bytes: number }[] = [];
  for (const assetUri of assetUris) {
    let bytes = 0;
    try {
      if (pickedVideoMeta(assetUri) || isLocalVideoUri(assetUri)) {
        bytes = await fileSize(assetUri);
      } else {
        const p = await this.photoApi.prepare(assetUri);
        if (p) { this.prepared.set(assetUri, p.fileUri); bytes = p.bytes; }
      }
    } catch {
      // 測れなくても送信はする (save 側で変換し直す)
    }
    files.push({ key: assetUri, bytes });
  }
  uploadProgress.begin(files);
}

動画は選んだ時点で書き出しが済んでいるので、ファイルの大きさをそのまま使う。 prepare を呼ぶのは、記録を作る・直す UseCase の4つだ。どれも送る前に await this.photoStorage.prepare?.(uris) を1行足しただけで済んだ。

測れなかったファイルは0バイトとして件数にだけ入れることでユーザーには特に不利益がある UX にはならずに済みそうだ。

進捗(プログレス)は1か所に集めて、100ミリ秒ごとに描く

途中経過を受け取る先は、小さなストアにした。uploadProgress という名前で、やることは4つしかない。

呼ぶもの いつ すること
begin(files) 送る前 全ファイルの大きさを持って始める
report(key, sent, total) 送信中 送信済みバイト数を更新する
done(key) 1件送り終えた その1件を満タンにする
end() 失敗した、または保存中の画面が閉じた 片づける

画面は useSyncExternalStore で読む。

描画ルールは3つ。

  1. 送信済みは後ろに戻さない。送り直しで0から数え直しても、表示は下がらない
  2. 合計は超えない。送信側が本当の大きさを知らせてきたら、合計のほうを直す
  3. 描き直しは100ミリ秒に1回まで

1と2はテストで書いた。

it('送信済みは後退しない (再送で 0 から数え直しても戻らない) し、合計を超えない', () => {
  uploadProgress.begin([{ key: 'a', bytes: 1_000 }]);
  uploadProgress.report('a', 800);
  uploadProgress.report('a', 100);
  flush();
  expect(uploadProgress.snapshot().sentBytes).toBe(800);
  uploadProgress.report('a', 5_000);
  flush();
  expect(uploadProgress.snapshot().sentBytes).toBe(1_000);
});

3は、プロトタイプ実装の段階では特に作っていない。

実機確認で問題発生

写真で試したらめちゃくちゃ良かった。これこれ!これがあれば待たされている間、不安になることはなく、ちゃんと進捗していることがリアルタイムに分かるので、全然待てる。

しかし、動画のような思いファイルで試したら問題が起きた。

「アップロードしたら数十秒待つことを確認したら瞬間的に完了してしまった画面になり、実際にはアップできていなかった、そしてフリーズして画面が動かせなくなった。」

シミュレーターでは再現しなかった。20 MB の動画を送ると、6.3、18、20 MB と進んで記録もできる。シミュレーターは回線が速すぎて、実機のように数十秒かけて送る状況にならない。

で、届く回数から考えた。途中経過は数十 KB ごとに届く。20 MB の動画なら数百から数千回になる。最初の版は、その1回ごとに合計を計算し直して画面を描き直していた。 JS のスレッドが描き直しに追いつかず、ハングしてしまうという挙動だった。固まっているあいだも、送信は端末の側で進む。戻った瞬間に100%へ飛ぶ。「瞬間的に完了」に見えたのは、これだと考えた。

図2。横軸が経過時間 0〜500ms の3本の帯。上の黄色「届く途中経過 report(sent)」には細い線がばらばらの間隔でびっしり並ぶ。中の赤「前の作り 届くたびに描く」には、同じ位置に太い線がびっしり並ぶ。下の緑「いまの作り まとめて描く」には、約100ms ごとに太い線が4本と、340ms 付近に白い線が1本だけ。下に赤い箱「描き直しが追いつかない そのあいだも送信は進み、戻ると一気に 100% に飛ぶ」と、緑の箱「最後の値だけ描く 1件終わった done (白) だけは待たずに描く」

直しは短い。report は値を書き換えるだけにして、描き直しはタイマーにまとめた。

/**
 * 送信の途中経過は数十 KB ごとに届く (20 MB の動画なら数百〜数千回)。
 * そのたびに描き直すと JS スレッドが追いつかず画面が固まるので、まとめて 1 秒に 10 回までにする
 */
const FLUSH_INTERVAL_MS = 100;
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function emitThrottled(): void {
  if (flushTimer) return;
  flushTimer = setTimeout(() => { flushTimer = null; emit(); }, FLUSH_INTERVAL_MS);
}

100ミリ秒のあいだに何回届いても、描くのは最後の値の1回だけだ。1件送り終えた done だけは待たずに描き、溜まっていたタイマーは捨てる。 バーの側は、届いた割合へ320ミリ秒かけて滑らかに追いつく。1秒10回でも、カクカクには見えない。

原因はもう1つあった。最後の1件を送り終えた瞬間に、バーが消えて点の明滅に戻っていた。 記録の保存はその後も続いている。動画ならサムネイルも送る。なのに画面は「終わった」顔をしていた。

図3。上の赤い枠「前の作り」は、送信中 (バーが伸びる) → 最後の1件 done(key) → 点の明滅 (バーが消える) の3つの箱。下に「記録の保存はまだ続いているのに、終わったように見えた」。下の緑の枠「いまの作り」は、送信中 (1件ずつ done) → 仕上げています (100% のまま、サムネイルと記録の保存を待つ) → 画面が閉じる (閉じた側が end())。送信中から点線で、失敗 (すぐ end()。エラーの表示へ) に分かれる。横に「成功でも失敗でも、片づけるのは end() の1か所。ストアは自分から消えない」

いまは、全部送り終えてもストアは自分から消えない。100%のまま「仕上げています」と出し続ける。片づけるのは、保存中の画面が閉じたときの end() だけだ。 冒頭の実際の画面を撮ったときは、送信中の14枚に対して「仕上げています」が19枚続いた。最後の待ちのほうが長かった。前の作りでは、この時間ずっと「完了」に見えていたことになる。

固まる原因の見立ては、実機のログで確かめたわけではない。直した版をシミュレーターで通し、37、89、100%と進んで保存まで終わることは見た。

バーは区切りと葉で、Gadenin のものにした

見た目への注文は「正しいMBのスケールですごくおしゃれなプログラスバー」「Gadeninらしさを全面に出すように」「プログラスバー自体はモダンなもので」だった。

図4。保存中の画面のバーを拡大し、6つの説明が線で部品を指している。例は写真2枚と動画1本。上に「1 / 3 件」と「15 / 34 MB」、その下にバー。バーは小さい区切り2つが満タンで、大きい区切り1つが途中まで緑のグラデーションで伸び、先端に緑の葉が乗る。伸びた部分に斜めの光の帯。下に「44%」。説明は、件数 (送り終えた数 / 全部)、先端の葉 (320ms かけて追いつく)、MB (十進。10未満は小数1桁)、割合 (100% で「仕上げています」)、光 (伸びた所だけ。視差効果を減らす設定で止める)、区切り (幅はファイルの大きさに比例)

いちばん効いたのは、バーをファイルごとに区切ったことだ。区切りの幅は、そのファイルの大きさに比例させた。 写真2枚と30 MB の動画なら、写真は左端の小さな2粒になる。動画の区切りが伸び始めると「いま重いのはこれだ」と分かる。数字を読まなくても、待つ理由が見える。

数字は十進の MB にした。1 MB は1,000,000バイトで、iPhone の「ファイル」や設定の表示と同じだ。10 MB 未満は小数1桁、それ以上は整数で出す。 1,048,576で割ると、iPhone で見た動画の大きさとバーの合計が合わない。正しい MB のスケールとは、持ち主が別の画面で見た数字と合うことだと考えた。

export function formatMegabytes(bytes: number): string {
  const mb = Math.max(0, bytes) / 1_000_000;
  if (mb === 0) return '0';
  if (mb < 0.1) return '0.1';
  if (mb < 10) return mb.toFixed(1);
  return String(Math.round(mb));
}

Gadenin らしさは、先端の葉に任せた。記録シートの上の茎の進み具合にも、同じ葉が乗っている。色はミントから深い緑へのグラデーションで、送り終えると葉が深い緑に変わる。UIUX のこだわりはモバイルアプリならでは。 光の帯は、伸びた部分の中だけをゆっくり流れる。「視差効果を減らす」を入れている人には流さない。葉と数字はそのまま動く。

部品ごとの値はこうなった。

部品 値
バーの太さ 12pt、区切りの間は3pt
先端の葉 18pt。320ミリ秒かけて追いつく
光の帯 幅70pt、1.8秒かけて流れる
区切りの最小幅 全体の2%。小さい写真でも消えない

動画は、送る前にも待っていた

送信の進捗が出るようになると、今度は動画を選んでから追加されるまでの待ちが目立った。 「準備中というステータスが数十秒続く」「準備中が何をしてるのかユーザーはわからないのでこの待機時間もストレス」。

その数十秒、iPhone の中では動画を書き出し直していた。expo-image-picker のプリセットに任せていたので、進み具合が取れない。ビットレートも決められず、53秒の動画が50 MB を超えて、何も言わずに追加されないこともあった。

で、書き出しを自前の Expo Module にした。AVAssetReader と AVAssetWriter で、長辺1920まで縮め、H.264 の平均5 Mbps で書く。書き出したフレームの時刻を動画の長さで割れば、進み具合になる。1%進むたびに JS へイベントを送る。 ボタンの中が左から濃い青で満ちていき、「動画変換中 n%」と出る。53秒の 4K 動画は34 MB になった。冒頭の画面で送っているのが、この動画だ。

考え方は送信のバーと同じだった。端末の中でしか分からない数字を、1つだけ JS に渡す。中身は渡さない。

葉が揺れて、点が3つ明滅するだけの画面は、もうない。 いまは、34 MB のうち何 MB 送ったかを数字で言う。

作った本人の僕が、ようやく信用できる「保存中」になった。

関連記事

Gadenin 栽培記録アプリ・無料アプリを入れる