懐疑から確信へ:Fieldy が AI ウェアラブルに Expo を採用するまで
Fieldy の AI ウェアラブルは BLE の長接続とバックグラウンド音声を維持する必要があった。Expo へ移行したことでアプリのサイズは 25% 削減され、デプロイは全工程で自動化された。
日本語
コピー

この記事は Adomas Valiukevičius が執筆した——Fieldy の共同創業者兼エンジニアリング責任者で、現実世界で物事を覚え、情報を整理し、行動に移すための AI ウェアラブルアシスタントを作っている。
…
Fieldy では AI ウェアラブルアシスタントを開発している。アプリは Bluetooth ハードウェアデバイスと接続し、音声転送、要約生成、そしてユーザーに代わって操作を実行する。
Bluetooth Low Energy(BLE)とバックグラウンドプロセスに大きく依存しているため、性能には特に敏感だ。RAM 使用量とアプリサイズは常に監視している。OS はいつでもバックグラウンドタスクを殺す口実を探しているからだ。
正直に言うと、以前は Expo を真剣に検討したことはなかった。チームに使った経験のある者はいない。アンチ Expo というわけではなく、単に知らなかっただけだ。懸念していたのは、我々の重いネイティブ要件の上に「マネージド」な抽象レイヤーを重ねることでサイズが膨らんだり、低レイヤーに直接触れたいときに邪魔されたりするのではないか、ということだった。

その後、Tomasz Sapeta を採用した。投資家の Inovo.vc が紹介してくれた人物で、長期休暇中だった彼がパートタイムで加わり、性能最適化を手伝ってくれた。
彼が Expo を勧めた。追加コストには懐疑的だったが、彼を信頼していた。リポジトリを彼に渡し、「好きにやっていい」と言った。
以下が実際に起きたこと、得られた数字、そして道中でやらかした混乱だ。
監査
将来に備えて監視の仕組みを入れることと、今すでに壊れているものを直すために徹底的な監査をすることは別物だ。この「bare」アプリには長年ゴミが積み重なっていることに気づき、package.json を一行ずつ見直す必要があった。
すべてのパッケージを調べ、実際にどこまで使われているかを確認し、代替を調査し、既知の問題に印をつけた。影響を可視化するため、起動コマンドを変更してデフォルトで Expo Atlas を走らせるようにした。Atlas は Expo に同梱されているバンドル分析ツールで、JS バンドルを何が膨らませているのかを視覚的に示してくれる。
"scripts": {
"start": "EXPO_ATLAS=1 APP_VARIANT=development expo start",
}

スペースを無駄に食っている大きな塊が三つ見つかった。
JS バンドルの整理
Atlas はすぐにいくつかの巨大なパッケージを炙り出した。ひとつの PR で、冗長な依存を三つ潰した。
react-native-calendar-events:権限のチェックにしか使っておらず、その権限はとっくに不要になっていた。
react-native-calendars:Wix の重量級ライブラリ。使っていたのはたったひとつの日付ピッカーのためだけ。おまけに lodash を引き連れてきて、これだけでバンドル全体の 4% を占めていた。
react-native-date-picker:時間ピッカーにしか使っていなかった。
三つとも削除し、シンプルな自作の日付ピッカーコンポーネントに置き換えた。JS バンドルは 10% 縮み、ネイティブ依存も三つ減った。

tree shaking の修正
lodash の話で言えば、react-native-calendars を消しただけでは半分しか解決していない。Atlas は、我々自身のコードが約 700KB の Lodash を抱え込んでいることも見つけた。ライブラリ全体をインポートしていたからだ(import { debounce } from "lodash")。個別パスではなく。
date-fns も同じ状況だった。どちらも直接インポート(例えば import debounce from "lodash/debounce")に変えたところ、バンドルサイズが急減した。削除したライブラリと修正したインポートを合わせて、JS バンドルは 2MB 以上削れた。
tree shaking の詳細はこちら——https://docs.expo.dev/guides/tree-shaking/
アプリは 19.8MB から 17.8MB になった。


不要なフォントの削除
Atlas で JS バンドルを整理する傍ら、ネイティブビルドのリソースを手作業で確認したところ、もっと大きなものが見つかった。SF-Pro.ttf などのフォントで、合計 18.6 MB あった。
SF Pro は iOS のシステムフォントで、バンドルに含める必要はまったくない。まして Android に Apple のシステムフォントを同梱するのは無意味だ。削除。これだけでアプリのバイナリは 5 MB 近く小さくなった。

React Native でまともな fetch
Web ではファイルのアップロードもストリーミングも造作もない。React Native ではこれがずっと苦痛で、さまざまなプライベートライブラリを混ぜ合わせる必要があった。
Expo SDK 54 でネイティブアプリが WinterCG 仕様に準拠した。噛み砕くと、ネイティブの fetch が Web の fetch と同じように動くようになったということだ。ネイティブのワークアラウンドを使うのはやめ、プラットフォーム標準を使い始めた。
ファイルアップロード
以前は multipart の音声アップロードを扱うだけで、react-native-fs と RNBlobUtil に依存する必要があった。ファイルをディスクに書き、プライベートな blob ツールで包み、アップロードを手動で管理する。今は標準の File オブジェクト(expo-file-system 由来)をそのまま fetch に渡すだけだ。
// Write base64 string to disk first...
await RNFS.writeFile(path, data, "base64");
// Then wrap it with a specific library
requestData.push({
name: "audio",
data: RNBlobUtil.wrap(path),
});
// Use a proprietary fetch method
await RNBlobUtil.fetch("POST", url, ...);
// Standard File API
const file = new File(Paths.cache, "audio.raw");
file.write(new Uint8Array(data));
// Standard FormData
const formData = new FormData();
formData.append("audio", file);
// Standard Fetch
await fetch(url, { method: "POST", body: formData });
ストリーミング
ストリーミング応答も同じだった。以前は XHR で文字列インデックスを手動で追跡していたが、今は標準の ReadableStream を使っている。
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));
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); // Clean binary chunk
}
デプロイの自動化
ブランチ戦略はシンプルだ。main は TestFlight に対応する。安定版と見なすが、必ずしもリリース可能とは限らない——機能の完成を待ちながら、テスターやエンジニアリングチームからのフィードバックを集めている段階だ。main に新しい fingerprint が付き、本番投入の準備が整ったら、release/x ブランチを作成してネイティブビルドをアプリストアに提出する。以降、release ブランチへのプッシュはそのバージョンのユーザーに OTA アップデートとして配信される。fingerprint が変わればワークフローは失敗し、Slack で通知が来る。

Continuous Native Generation(CNG)の価値
Continuous Native Generation (CNG) のことは最初まったく理解していなかったし、今でも完全に理解しているとは言えない。それでも、使うことで確かに恩恵は受けている。
考え方はシンプルだ。ios/ と android/ のディレクトリを git にコミットするのをやめ、ビルドのたびに CNG に app.config.js から再生成させる。ネイティブコードはビルド成果物になり、ソースオブトゥルースではなくなる。ネイティブ層全体の .gitignore だと思えばいい。
周りの人にも聞いてみた。「ネイティブディレクトリを毎回再生成するとなぜ良いのか?」 ——「アップグレードが楽になる」以外に、納得できる答えはほとんど返ってこなかった。だが EAS Workflows がそれを要求してくる。デプロイを自動化したかったので、従うしかなかった。
結果として、想像していたよりずっと役に立った。ネイティブ層が壊れたときの修正は、たいていコマンド一発で済む。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 });
}
};
// Only build ARM architectures (skip x86/x86_64)
set("reactNativeArchitectures", "armeabi-v7a,arm64-v8a");
return config;
});
};

EAS Workflows で CI パイプラインを自動化する
EAS Workflows は Expo が提供する CI/CD サービスで、ビルド、アプリストアへの提出、OTA 更新の配信までを担う。すべて git push をトリガーに動く。つまり JS だけの変更は、アプリストアの審査を待たずにすぐユーザーの手元に届く。EAS はさらに fingerprinting でネイティブ依存のハッシュを計算する。ネイティブ部分が変わっていなければフルビルドをスキップし、OTA 更新をそのまま配信する。私たちのデプロイのほとんどは数分で終わる。30 分ビルドして、アプリストアの審査を何日も待つ必要はない。
設定は Expo ドキュメントの Deploy to Production の例をそのまま参考にした。
name: Deploy to production
defaults:
tools:
corepack: true
on:
push:
branches:
- main
- "release/**"
# We don't need to run it on every change, so we list paths
# that can potentially affect the build or update.
paths:
# - ".eas/workflows/**"
- "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 スレッドはバックグラウンドでは信用できない。省電力のためにシステムがいつでも殺してしまうし、Headless JS は事実上メンテナンスされていない。私たちは react-native-ble-plx と react-native-ble-manager も試したが、どちらもこの用途には十分な信頼性がなかった。そこで BLE 層全体を Expo Modules でネイティブの Swift と Kotlin に書き直した。
Expo Modules とライフサイクルリスナー
Expo Modules を使ったもう一つの場面は、アプリ終了の通知だ。ユーザーが Fieldy を強制終了すると、録音が止まることを知らせる必要がある。だがその瞬間、JavaScript はすでに死んでいる。AppDelegate subscriber を使えば簡単だ。
{
"platforms": [
"apple"
],
"apple": {
"appDelegateSubscribers": [
"FieldyAppDelegateSubscriber"
]
}
}
import UserNotifications
import ExpoModulesCore
public class FieldyAppDelegateSubscriber: ExpoAppDelegateSubscriber {
...
public func applicationWillTerminate(_ application: UIApplication) {
let content = UNMutableNotificationContent()
content.title = "Open Fieldy!"
content.body = "Don't close Fieldy - new recordings may not be captured!"
content.sound = UNNotificationSound.default
content.interruptionLevel = .timeSensitive
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1.0, repeats: false)
let request = UNNotificationRequest(identifier: "app-termination-warning", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
// Handle error if needed
}
}
...
}

私たちが犯したミス
複数のアプリバリアント
もちろん、うまくいってばかりではなかった。OTA(Over-The-Air)更新で重大なミスを犯した。
私たちは複数のアプリバリアント(development と production)を使い、Bundle ID をそれぞれ分けている。RevenueCat はこの ID でサブスクリプションを検証する。
あるとき、ローカルマシンから eas update でデプロイを実行した。何が問題だったか。EAS は私のローカルの .env ファイルを読み、それが APP_VARIANT=development に設定されていた。開発用の設定を誤って本番 channel に push してしまったのだ。
ユーザーが更新すると、本番環境のアプリが突然、開発用キーで RevenueCat に接続し始めた。ペイウォールが落ちた。 収益を失い、ユーザーは混乱した。
修正は簡単だった。ローカルからのデプロイをやめたのだ。今はすべてのリリースを EAS Workflows 経由で行い、ビルド profile に応じて環境変数が自動で処理される。
おわりに
これを書いている時点で、Expo を使わずに React Native アプリを構築するのは想像しにくい。本番環境での使用はまだ 9 か月だが、それでもそう思う。この方向に背中を押してくれた Tomasz Sapeta のおかげが大きい。
この記事を書いたのは、非常に懐疑的なエンジニアチームであっても Expo に移行でき、最終的にはその恩恵を心から享受できると示したかったからだ。