懐疑派から転向者へ:FieldyがAIウェアラブルにExpoを採用した経緯
ユーザー • React Native • 2026年3月24日 • 10分で読める
Adomas Valiukevičius
ゲスト著者
FieldyのAIウェアラブルには持続的なBLE接続とバックグラウンドオーディオが必要です。彼らはExpoに移行し、アプリサイズを25%削減し、すべてのデプロイを自動化しました。
これは、人々が現実世界で記憶し、整理し、行動することを支援するAIウェアラブルアシスタントを構築しているFieldyの共同創設者兼エンジニアリングリードであるAdomas Valiukevičiusからのゲスト投稿です。
Fieldyについて
Fieldyでは、AIウェアラブルアシスタントを構築しています。このアプリは物理的なBluetoothデバイスに接続し、音声転送を処理し、要約を生成し、ユーザーに代わってアクションを実行します。
Bluetooth Low Energy(BLE)とバックグラウンドプロセスに大きく依存しているため、パフォーマンスには非常に注意を払っています。OSは常にバックグラウンドタスクを終了する理由を探しているため、RAMの使用量とアプリサイズを常に監視しています。
正直に言うと、私たちは以前Expoを真剣に検討したことはありませんでした。誰も試したことがありませんでした。「反Expo」ではありませんでしたが、単に知識不足でした。重いネイティブ要件の上に「管理された」レイヤーを追加することで、肥大化が生じたり、必要な時に低レベルアクセスがブロックされたりするのではないかという恐れがありました。
そして、Tomasz Sapetaを雇いました。Inovo.vcの投資家が紹介してくれ、彼が長期休暇中だったため、パートタイムでパフォーマンス改善を手伝ってもらうことになりました。彼がExpoを提案しました。
最初はオーバーヘッドについて懐疑的でしたが、彼を信頼していました。リポジトリを渡して「やってもらおう」と言いました。
以下は、実際に何が起こったか、達成した数値、そして途中で作った混乱についてです。
監査
将来のために物事を保存する対策を追加することと、現在壊れているものを修正するための深い監査を行うことには違いがあります。
私たちの「bare」アプリが時間の経過とともに多くの不要なものを蓄積していることに気づき、package.jsonのすべての行を手動で検査する必要がありました。
すべてのパッケージを検査し、実際の使用状況を確認し、代替案を調査し、既知の問題にフラグを立てました。
影響を可視化するために、デフォルトでExpo Atlasを実行するようにstartコマンドを変更しました。AtlasはExpoに付属するバンドル分析ツールで、JSバンドルサイズに何が寄与しているかの視覚的な内訳を提供します。
"scripts": {
"start": "EXPO_ATLAS=1 APP_VARIANT=development expo start",
}
理由もなく容量を消費している3つの主要な領域を発見しました。
JSバンドルのクリーンアップ
Atlasは、いくつかの大きなパッケージをすぐに見つけるのに役立ちました。単一のPRで、3つの冗長なパッケージを特定しました:
- react-native-calendar-events:もはや必要のない権限をチェックするためだけに使用していました。
- react-native-calendars:これは(Wixによる)重いライブラリです。日付ピッカーのためだけに使用していました。lodashを引き込んでおり、それだけでバンドル全体の4%を占めていました。
- react-native-date-picker:時間ピッカーのためだけに使用していました。
3つすべてを削除し、シンプルなカスタム日付ピッカーコンポーネントに置き換えました。JSバンドルサイズ全体の10%を節約し、3つのネイティブ依存関係を一度に削除しました。
ツリーシェイキングの修正
lodashについて言えば、react-native-calendarsを削除することは戦いの半分に過ぎませんでした。Atlasは、特定のパスではなくライブラリ全体をインポートしていた(import { debounce } from "lodash")ため、自分たちのコードで約700KBのLodashをまだ運んでいることを明らかにしました。
date-fnsでも全く同じパターンを見ました。
両方を直接インポート(例:import debounce from "lodash/debounce")に切り替えると、バンドルサイズが急激に減少しました。
削除されたライブラリとこれらの修正されたインポートの間で、2MB以上のJSバンドルを削減しました。
ツリーシェイキングについて詳しく読む - https://docs.expo.dev/guides/tree-shaking/
アプリは19.8MBから17.8MBになりました:
未使用フォントの削除
AtlasがJSバンドルをクリーンアップしている間、ネイティブビルドアセットの手動検査により、より大きなモンスターが明らかになりました:18.6MBの重さのSF-Pro.ttfとその他のフォント。
SF ProはiOSのシステムフォントです — バンドルする必要はありません。そして、AndroidでAppleのシステムフォントをバンドルすることは全く意味がありません。
削除しました。それだけで、アプリバイナリは約5MB減少しました。
React Nativeでの適切なfetch
Webでは、ファイルのアップロードやデータのストリーミングは簡単です。React Nativeでは、これは歴史的に痛みを伴い、独自ライブラリの組み合わせが必要でした。
Expo SDK 54は、ネイティブアプリにWinterCG準拠をもたらしました。平易な英語で言えば:ネイティブfetchをWebのfetchのように動作させます。
ついにネイティブの回避策の使用をやめ、プラットフォーム標準の使用を開始しました。
ファイルアップロード
以前は、マルチパートオーディオアップロードを処理するためにreact-native-fsとRNBlobUtilに依存していました。ファイルをディスクに書き込み、独自の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");
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);
}
自動デプロイメント
私たちのブランチ戦略はシンプルです:mainはTestFlightを反映します。安定していると考えられますが、必ずしもリリース準備ができているわけではありません — まだ機能の着地を待ち、テスターやエンジニアリングチームからのフィードバックを収集しています。
mainに新しいフィンガープリントがあり、本番環境の準備ができたら、release/xブランチを作成し、ネイティブビルドをストアに提出します。そこから、releaseブランチへのプッシュは、そのバージョンのユーザーにOTAアップデートを公開します。
フィンガープリントが変更された場合、ワークフローは失敗し、Slackで通知されます。
継続的ネイティブ生成(CNG)の価値
最初は継続的ネイティブ生成(CNG)を理解していませんでしたし、今でも完全に理解しているとは言えません。しかし、確実にその恩恵を受けました。
アイデアはシンプルです:ios/とandroid/フォルダをgitにコミットする代わりに、CNGはビルドするたびに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;
});
};
CIパイプライン自動化のためのEAS Workflows
EAS WorkflowsはExpoのCI/CDサービスです。ビルド、アプリストアへの提出、OTAアップデートのプッシュを処理し、すべてgitプッシュによってトリガーされます。
これにより、アプリストアレビューを通すことなく、JS専用の変更をユーザーに即座に配信できます。
EASはまた、フィンガープリンティングを使用してネイティブ依存関係のハッシュを計算します — ネイティブに変更がない場合、フルビルドをスキップしてOTAアップデートをプッシュします。
私たちのデプロイメントのほとんどは数分で完了し、30分のビルドに続く数日のアプリストアレビューではありません。
私たちの設定は、Expoドキュメントの本番環境へのデプロイ例に従って実装されました:
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を使用すると、JSと並行してSwiftとKotlinを書くことができ、2つのレイヤーがきれいに通信します。
Fieldyは物理デバイスへの持続的なBluetooth接続に依存しています。JSスレッドはバックグラウンドで信頼できません — OSはバッテリーを節約するためにそれを終了することを好み、Headless JSは事実上放棄されています。
react-native-ble-plxとreact-native-ble-managerを試しましたが、どちらも私たちのユースケースには十分信頼できませんでした。
そこで、BLEレイヤー全体をネイティブで書きました。