Users • React Native • March 24, 2026 • 10 minutes read
Adomas Valiukevičius — Guest Author
FieldyのAIウェアラブルは、永続的なBLE接続とバックグラウンドでのオーディオ処理を必要とします。私たちはExpoへ移行し、アプリサイズを25%削減し、すべてのデプロイを自動化しました。
これはFieldyの共同創業者でエンジニアリングリード、Adomas Valiukevičiusによるゲストポストです。Fieldyは現実世界での記憶や整理、行動を支援するAIウェアラブルアシスタントを開発しています。
はじめに
Fieldyでは物理的なBluetoothデバイスに接続し、オーディオの受け渡しや要約生成、ユーザーに代わってのアクション実行を行うAIウェアラブルを作っています。BLE(Bluetooth Low Energy)やバックグラウンドプロセスに大きく依存しているため、パフォーマンスには非常に神経質です。OSは常にバックグラウンドタスクを終了する理由を探しているので、RAM使用量やアプリサイズを継続的に監視しています。
正直に言うと、以前はExpoを真剣に検討していませんでした。誰も試したことがなく、"反Expo"というわけではなく、単に知識が足りなかったのです。重いネイティブ要件の上に“managed”レイヤーを重ねると余計な肥大化が起きたり、必要なときにネイティブへアクセスできなくなるのではないかという恐れがありました。
そこでTomasz Sapetaを採用しました。投資家のInovo.vcが彼を紹介してくれて、長期休暇中にもかかわらずパートタイムでパフォーマンス改善を手伝ってくれました。彼が提案したのがExpoです。最初はオーバーヘッドを疑いましたが、彼を信頼してレポジトリを渡し、「任せてみよう」と言いました。
以下は実際に起きたこと、達成した数値、そしてその過程での混乱の記録です。
監査(The audit)
単なる将来のための対策追加と、今壊れているものを直すための深い監査は別物です。私たちの"bare"アプリは時間とともに不要なものが溜まっており、package.jsonの行ごとに手動検査が必要でした。すべてのパッケージを確認し、本当に使っているか、代替はないか、既知の問題がないかを調べ、フラグを立てました。
影響を可視化するために、startコマンドを修正してデフォルトでExpo Atlasを実行するようにしました。AtlasはExpoに同梱されたバンドル解析ツールで、JSバンドルサイズに何が寄与しているかを視覚的に示してくれます。
"scripts": {
"start": "EXPO_ATLAS=1 APP_VARIANT=development expo start",
}
Atlasのおかげで、理由なく容量を食っている主要な領域が3つ見つかりました。
JSバンドルのクリーンアップ
Atlasは大きなパッケージを即座に教えてくれました。1つのPRで次の冗長なパッケージを特定しました:
react-native-calendar-events: パーミッションチェックのためだけに使っていましたが、今は不要でした。
react-native-calendars: Wix製の重いライブラリで、日付ピッカーだけに使っていました。lodashを引き込み、これだけでバンドルの4%を占めていました。
react-native-date-picker: 時刻ピッカー用に使っていました。
これら3つを削除し、シンプルなカスタム日付ピッカーコンポーネントに置き換えました。その結果、JSバンドル全体の約10%を節約し、3つのネイティブ依存を一度に削除できました。
tree shaking(ツリーシェイキング)修正
react-native-calendarsを削除しただけでは不十分でした。Atlasは私たちのコードで約700KBのlodashが残っていることを示しました。理由は、import { debounce } from "lodash"のようにライブラリ全体をインポートしていたためです。同様のパターンがdate-fnsでも見つかりました。
両方とも特定パスからの直接インポート(例:import debounce from "lodash/debounce")に変え、バンドルサイズは大幅に減少しました。
ライブラリ削除とインポート修正で、JSバンドルを2MB以上削減しました。
Read more about tree shaking - https://docs.expo.dev/guides/tree-shaking/
最終的にアプリは19.8MBから17.8MBになりました。
使われていないフォントの削除
AtlasがJSバンドルを整理している間、ネイティブビルド資産の手動検査でさらに大きなモンスターを見つけました:SF-Pro.ttfなどのフォント群で合計18.6MBを占めていました。SF ProはiOSのシステムフォントなのでバンドルする必要はありませんし、AndroidでAppleのシステムフォントをバンドルする意味もありません。これらを削除しただけで、バイナリサイズはほぼ5MB減少しました。
React Nativeでの正しいfetch(Proper fetch on React Native)
ウェブではファイルアップロードやストリーミングは簡単ですが、React Nativeでは歴史的に面倒で、専用ライブラリの混在を必要としていました。Expo SDK 54でネイティブアプリに対してWinterCG準拠が導入され、平たく言えばネイティブのfetchがウェブのfetchのように動くようになりました。これによりネイティブのワークアラウンドをやめ、プラットフォーム標準を使えるようになりました。
ファイルアップロード
以前はreact-native-fsやRNBlobUtilに頼ってmultipartの音声アップロードをしていました。ファイルを書き出して、独自のblobユーティリティでラップし、手動でアップロードを管理していました。現在は標準のFileオブジェクト(expo-file-system由来)を使い、それをfetchに渡しています。
Before:
await RNFS.writeFile(path, data, "base64");
requestData.push({ name: "audio", data: RNBlobUtil.wrap(path) });
await RNBlobUtil.fetch("POST", url, ...);
After:
const file = new File(Paths.cache, "audio.raw");
file.write(new Uint8Array(data));
const formData = new FormData();
formData.append("audio", file);
await fetch(url, { method: "POST", body: formData });
ストリーミング
ストリーミングレスポンスも同じ話です。以前はXHRで文字列インデックスを手動追跡していましたが、今は標準のReadableStreamを使います。
Before:
const xhr = new XMLHttpRequest()
xhr.open("POST", url)
xhr.setRequestHeader("Content-Type", "application/json")
// Manually tracking string indices
let lastIndex = 0
xhr.onprogress = () => {
const currIndex = xhr.responseText.length
if (lastIndex === currIndex) return
const chunk = xhr.responseText.substring(lastIndex, currIndex)
processChunk(chunk)
lastIndex = currIndex
}
xhr.onerror = (err) => handleError(err)
xhr.onload = () => console.log("Done")
xhr.send(JSON.stringify(body))
After:
import { fetch } from 'expo/fetch';
const resp = await fetch(url, { method: "POST", headers: { Accept: 'text/event-stream' }, body: JSON.stringify(body) });
const reader = resp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
processChunk(value);
}
自動デプロイ(Automated deployment)
私たちのブランチ戦略はシンプルです:mainはTestFlight相当を反映します。安定しているが必ずしもリリース準備完了ではない状態です。mainに新しいフィンガープリントが入り、すべてが本番向けならrelease/xブランチを作り、ネイティブビルドをストアへ提出します。その後、releaseブランチへのプッシュはそのバージョンのユーザーへOTAアップデートを公開します。
フィンガープリントが変わっていたらワークフローは失敗してSlackに通知されます。
Continuous Native Generation(CNG)の価値
当初、Continuous Native Generation(CNG)を理解していませんでしたし、今でも完全には理解していないかもしれません。それでも恩恵は受けました。アイデアは単純で、ios/やandroid/フォルダをgitにコミットする代わりに、毎回app.config.jsからそれらを再生成するというものです。ネイティブコードはソースの真実ではなくビルド成果物になります。ネイティブ層全体の.gitignoreのように考えてください。
"なぜ毎回ネイティブフォルダを再生成するのが有益なのか"と聞き回りましたが、"アップグレードが楽になる"以外に説得力のある答えはあまり得られませんでした。しかしEAS Workflowsがそれを必要としており、自動化を進めるために採用せざるを得ませんでした。結果的に期待以上に有用でした。
ネイティブ層で何か壊れても修正は多くの場合1コマンドで済みます:
bun expo prebuild --clean
古いネイティブの状態で悩むことはなくなりました。CNGはビルドごとにネイティブコードを再生成するので、生成結果を修正したい場合はConfig Pluginsを使って出力を変更します。コードを注入したり設定を調整したり、以前手作業でやっていたことを自動化できます。
例えば、gradle.propertiesを調整してx86アーキテクチャを取り除き、ビルドを40%高速化したプラグインは次のようになります。
const { withGradleProperties } = require("@expo/config-plugins");
module.exports = (config) => {
return withGradleProperties(config, (config) => {
const set = (key, value) => {
const existing = config.modResults.find((p) => p.key === key);
if (existing) { existing.value = value; }
else { config.modResults.push({ type: "property", key, value }); }
};
set("reactNativeArchitectures", "armeabi-v7a,arm64-v8a");
return config;
});
};
EAS WorkflowsによるCIの自動化
EAS WorkflowsはExpoのCI/CDサービスで、ビルドの実行、ストアへの提出、OTAの公開をgitのpushでトリガーします。これによりJSのみの変更をストア審査なしでユーザーへ即座に届けられます。EASはネイティブ依存のフィンガープリントを計算し、ネイティブに変更がなければフルビルドをスキップしてOTAを押す、という最適化を行います。多くのデプロイは数分で終わり、30分のビルド+数日かかる審査の流れから解放されました。
私たちの設定はExpoドキュメントのDeploy to Production例を参考に実装しました(抜粋):
name: Deploy to production
defaults:
tools:
corepack: true
on:
push:
branches:
- main
- "release/**"
paths:
- "assets/**"
- "modules/**"
- "src/**"
- "patches/**"
- "plugins/**"
- "app.config.js"
- "eas.json"
- "index.js"
- "metro.config.js"
- "package.json"
concurrency:
cancel_in_progress: true
group: ${{ workflow.filename }} - ${{ github.ref }}
jobs:
fingerprint:
name: Fingerprint
type: fingerprint
get_ios_build:
name: 🔍 Check for existing iOS build
needs: [fingerprint]
type: get
build_params:
fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
profile: production
submit_ios_build:
name: 🚀 Submit iOS Build
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
publish_ios_update:
name: 📤 Publish iOS update
needs: [get_ios_build]
if: ${{ needs.get_ios_build.outputs.build_id }}
type: update
params:
branch: production
platform: ios
send_slack_message_ios:
name: 📣 Send Slack message - iOS
after: [fingerprint, get_ios_build, build_ios, submit_ios_build, publish_ios_update]
type: slack
params:
webhook_url: XXXXXXX
payload:
blocks:
- type: header
text:
type: plain_text
text: "Finished deploying iOS "
Slackにもデプロイのステータスを送るようにしています。
Expo Modules(ネイティブの力)
Expoに関する一般的な懸念として、ネイティブコードから締め出されるのではないかというものがありますが、それは誤解です。Expo Modulesを使えばSwiftやKotlinでネイティブモジュールを書き、JS層ときれいに通信できます。
Fieldyは物理デバイスへの永続的なBluetooth接続に依存しています。JSスレッドはバックグラウンドで信頼できず、OSは省電力のために簡単にそれを殺しますし、Headless JSは事実上放置されています。react-native-ble-plxやreact-native-ble-managerも試しましたが、私たちのユースケースには十分に信頼できませんでした。そこで、BLEレイヤー全体をネイティブで実装しました。
(注:ここまでが投稿の本文に含まれていた範囲です。)