Expo でダウンロード進捗処理

はじめに

Kotlin で Android 開発もやっているからこそ、Expo(React Native)ってホント便利だなぁと実感するわけですが、一方で、予想だにしない変な所でハマってしまうことも確かにあるわけです。今回はファイルのダウンロード進捗処理にハマりました。

前提

やりたいこと

CDN(今回は Firebase Hosting を利用) に配置した JSON ファイルをダウンロードしつつ、その進捗を UI に反映。

プラットフォーム

今回は Android のみで動作確認を行なっています。

はじめにやったこと

HTTP クライアントとして axios を採用していたので、とりあえず下記のようなコードを試したのですが、

const { data, status, headers } = await axios({
  url,
  onDownloadProgress: (progressEvent) => {
    console.debug(progressEvent);
  },
});
console.debug(headers);

progressEvent の中身は下記の通りで、想定した内容になってくれません。

Event {
  "isTrusted": false,
  "lengthComputable": false,
  "loaded": -1,
  "total": -1,
}

また、レスポンスヘッダーが格納されているはずの headers の中身は下記の通りで、ダウンロード進捗計算には必要であろう Content-Length が見つかりません。

Object {
  "accept-ranges": "bytes",
  "cache-control": "public, max-age=0",
  "content-type": "application/json",
  "date": "Mon, 25 Jan 2021 05:14:36 GMT",
  "etag": "\"37f9f24f4a981a4ea0c4e9efa4ff98d3c10872717481c5be8a1d5084487272c4\"",
  "last-modified": "Mon, 25 Jan 2021 05:13:12 GMT",
  "strict-transport-security": "max-age=31556926; includeSubDomains; preload",
  "vary": "x-fh-requested-host, accept-encoding",
  "x-cache": "MISS",
  "x-cache-hits": "0",
  "x-served-by": "cache-tyo11938-TYO",
  "x-timer": "S1611551676.522891,VS0,VE793",
}

レスポンスヘッダーとして存在しているはず(プロキシを挟んでレスポンスを覗き見るとちゃんとありました)の Content-Length が見つからない点が不可思議過ぎて、アプリケーションレイヤーで何をやっているのか興味も出たので、実コードを調査することにしました。

コード調査

axios(v0.21.1) 編

そもそも axios の onDownloadProgress コールバックはドキュメント上、browser only とあります。React Native の場合どう扱われるのかが良く分かっていなかったので、まずはそこを調査しました。

結論から言うと、React Native で axios を利用する場合はアダプターとして xhrAdapter が選択されるので、onDownloadProgress コールバックは使えるようです(あくまでも axios レイヤーでは期待通りに呼び出されると言う意味。コールバック引数含めた実挙動の妥当性については別問題)。具体的には XMLHttpRequest が使えるなら(デフォルトで)xhrAdapter を選択するロジックになっていました。そして、React Native はドキュメント記載の通り、XMLHttpRequest が使えます。

React Native(v0.63.2) の XMLHttpRequest 編

この辺でセットアップされているようですが、axios 含めて外部から利用される XMLHttpRequest の実態は React Native が用意した独自 XMLHttpRequest クラスです。

そこから辿っていくと、Android の場合は、OkHttp利用していることが分かりました。

ちなみに、ちょっと脱線して、ドキュメントにも記載のある fetch も選択肢として調査したのですが、実態は whatwg-fetch であるため、結局は XMLHttpRequest に辿り着くようです。

OkHttp 編

コイツが曲者でした・・・。ドキュメントには、

It will add an Accept-Encoding header for transparent response compression unless the header is already present.

If transparent compression was used, OkHttp will drop the corresponding response headers Content-Encoding and Content-Length because they don’t apply to the decompressed response body.

等と記載があり、要は、良かれと思って OkHttp が勝手にヘッダーを操作してしまうようです。今回のケースだと、transparent compression という考え方により、Content-Encoding と Content-Length ヘッダーを削除してしまうそうな。

それを避けるため、試しにリクエストヘッダーに Accept-Encoding を明示的にセットしてみると、

const { data, status, headers } = await axios({
  url,
  headers: {
    'Accept-Encoding': 'gzip',
  },
  onDownloadProgress: (progressEvent) => {
    console.debug(progressEvent);
  },
});
console.debug(headers);

下記の通り、Content-Length ヘッダーが返ってくるようになりました。

Object {
  "accept-ranges": "bytes",
  "cache-control": "public, max-age=0",
  "content-encoding": "gzip",
  "content-length": "155388",
  "content-type": "application/json",
  "date": "Mon, 25 Jan 2021 05:18:40 GMT",
  "etag": "\"37f9f24f4a981a4ea0c4e9efa4ff98d3c10872717481c5be8a1d5084487272c4\"",
  "last-modified": "Mon, 25 Jan 2021 05:13:12 GMT",
  "strict-transport-security": "max-age=31556926; includeSubDomains; preload",
  "vary": "x-fh-requested-host, accept-encoding",
  "x-cache": "HIT",
  "x-cache-hits": "1",
  "x-served-by": "cache-tyo11960-TYO",
  "x-timer": "S1611551921.575225,VS0,VE1",
}

これで解決!と思いきや、progressEvent の中身は変わらず・・・。

このまま、OkHttp と独自 XMLHttpRequest の間を行き来しながら深掘りする先に幸せはない気がして来たので、本方針はここで断念・・・。

解決策

結局、一から考え直して、expo-file-system を使いました。コードをさまよった後なので、公式ドキュメントに記載されているのはとても安心感がありますね。

import * as FileSystem from 'expo-file-system';

const downloadResumable = FileSystem.createDownloadResumable(
  url,
  `${FileSystem.cacheDirectory}data.json`,
  undefined,
  (progress) => {
    const percentCompleted = progress.totalBytesWritten / progress.totalBytesExpectedToWrite;
    // ここで処理。
  },
);

try {
  const downloadResult = await downloadResumable.downloadAsync();
  if (downloadResult === undefined) {
    throw new Error('ダウンロードに失敗しました。');
  }
  const downloadFileContent = await FileSystem.readAsStringAsync(downloadResult.uri);
  return JSON.parse(downloadFileContent);
} catch (error) {
  console.error(error);
}

直接メモリに読み込むのではなく、ローカルキャッシュファイル書き込んだ上で読み込む方式になってしまいましたが、ダウンロード進捗を表示するような大きなファイルであれば最終的にはローカルキャッシュ検討必須となるので、結果、良いかと思います。

おわりに

元々の方針では、丸2日もハマって解決せずでしたが、方針転換してからは30分で実装完了・・・。涙が出ますね。


Product

「いつやるかは決めてないけれど、必ずやらなければいけないタスク」を忘れないためのアプリ