OpenAIExpo2026/03/24 13:45

From skeptic to convert: how Fieldy adopted Expo for their AI wearable

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

openaijamodel: gpt-5-mini-2025-08-07

懐疑から支持へ:FieldyがAIウェアラブルでExpoを採用した経緯

Key Points

  • アプリサイズ25%削減
  • 標準Fetchでファイル/ストリーミング簡素化
  • EASでCI/CDとOTA自動化

Summary

FieldyはBLE接続とバックグラウンドオーディオを必要とするAIウェアラブル向けアプリで、Expoへ移行することでアプリサイズを約25%削減し、デプロイを完全自動化しました。主な改善はJSバンドルの精査(Atlasで可視化)、不要フォントの削除、標準Web API相当のfetch/ReadableStream利用、CNGとEASによるネイティブ生成とCI/CDの導入です。

Key Points

  • バンドル監査: EXPO_ATLAS=1 APP_VARIANT=development expo startでAtlasを有効にし、未使用ライブラリ(例: react-native-calendar-events / react-native-calendars / react-native-date-picker)を削除してJSバンドルを削減(単一PRで約10%削減)。
  • tree-shaking最適化: import debounce from "lodash/debounce" 等、個別インポートへ切替えで約700KB〜数MB削減。結果としてJSは19.8MB→17.8MBに。
  • ネイティブ資産削減: iOSのSF-Proなど不要なシステムフォントをバンドルから除外し、バイナリサイズを約5MB削減。
  • ネイティブFetch/ファイル処理: Expo SDK(WinterCG準拠)で expo-file-systemFile + FormData + fetch を使い、RNFS / RNBlobUtil のラッパー不要に。ストリーミングは resp.body.getReader() を利用。
  • Continuous Native Generation (CNG): bun expo prebuild --clean を活用して ios/ android をビルドアーティファクト化。Config Pluginsでネイティブ出力を調整(例: gradleでx86除外)。
  • ビルド高速化と自動化: gradleプロパティで reactNativeArchitectures=armeabi-v7a,arm64-v8a 等を設定してビルドを約40%高速化。EAS Workflowsでフィンガープリント検出→OTA更新/ネイティブビルドを自動化し、Slack通知を統合。
  • ネイティブモジュール活用: 永続的なBLE接続などバックグラウンド要件はExpo ModulesでSwift/Kotlinのネイティブ実装を追加しJS層の制約を回避。

実務的なアクション項目:

  • Atlasで依存関係を可視化して不要パッケージを削除する。
  • lodash/date-fns等は個別パスでインポートするよう置換する。
  • システムフォントをバンドルしない(削除)ことを確認する。
  • Expo SDKの標準File/Fetch APIへ移行してファイルアップロードとストリーミングを簡素化する。
  • CNG+Config Pluginsでネイティブ生成を管理し、EASでCI/CDとOTAを導入する。

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

openaijamodel: gpt-5-mini-2025-08-07

懐疑派から改宗者へ:FieldyがAIウェアラブルにExpoを採用した経緯

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-fsRNBlobUtilに頼ってmultipartの音声アップロードをしていました。ファイルを書き出して、独自のblobユーティリティでラップし、手動でアップロードを管理していました。現在は標準のFileオブジェクト(expo-file-system由来)を使い、それをfetchに渡しています。

Before:

// 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, ...);

After:

// 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を使います。

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); // Clean binary chunk
}

自動デプロイ(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 }); }
    };
    // 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はネイティブ依存のフィンガープリントを計算し、ネイティブに変更がなければフルビルドをスキップして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-plxreact-native-ble-managerも試しましたが、私たちのユースケースには十分に信頼できませんでした。そこで、BLEレイヤー全体をネイティブで実装しました。

(注:ここまでが投稿の本文に含まれていた範囲です。)