ClaudeExpo2026/03/24 13:45

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

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

元記事

Quick Digest

要約

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

claudejamodel: claude-sonnet-4-20250514

FieldyがAIウェアラブル向けにExpoを採用し、アプリサイズ25%削減とデプロイ自動化を実現

Key Points

  • Expo Atlasによるバンドル分析でアプリサイズ25%削減
  • 標準Fetch APIでファイルアップロードとストリーミングを簡素化
  • EAS Workflowsで完全自動化されたCI/CDパイプライン構築

Summary

AIウェアラブルアシスタントを開発するFieldyが、当初は懐疑的だったExpoを採用し、パフォーマンス改善とデプロイメント自動化を実現した事例。BLEとバックグラウンド処理を多用するアプリで、アプリサイズを25%削減し、完全自動化されたCI/CDパイプラインを構築。

Key Points

  • バンドルサイズ最適化: Expo Atlasを使用してJSバンドルを分析し、不要なライブラリ(react-native-calendars、lodash等)を削除。Tree shakingの修正により2MB以上削減
  • ネイティブアセット最適化: 不要なSF-Proフォント(18.6MB)を削除し、アプリバイナリを5MB削減
  • 標準Web API対応: Expo SDK 54のWinterCG準拠により、ファイルアップロードとストリーミングで標準のFetch APIを使用可能
  • 継続的ネイティブ生成(CNG): ios/androidフォルダをgitから除外し、app.config.jsから自動生成。ビルド時間40%短縮
  • 自動デプロイメント: EAS Workflowsによりgit pushでTestFlightデプロイとOTAアップデートを自動化
  • ネイティブモジュール: Expo Modulesを使用してSwift/KotlinでBLE層を実装し、JSとの連携を実現

Full Translation

翻訳

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

claudejamodel: claude-sonnet-4-20250514

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

懐疑派から転向者へ: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-fsRNBlobUtilに依存していました。ファイルをディスクに書き込み、独自のblobユーティリティでラップし、アップロードを手動で管理する必要がありました。

今では、標準のFileオブジェクト(expo-file-systemから)を使用し、それをfetchに渡すだけです。

Before:

// 最初にbase64文字列をディスクに書き込み...
await RNFS.writeFile(path, data, "base64");

// 次に特定のライブラリでラップ
requestData.push({
  name: "audio",
  data: RNBlobUtil.wrap(path),
});

// 独自のfetchメソッドを使用
await RNBlobUtil.fetch("POST", url, ...);

After:

// 標準File API
const file = new File(Paths.cache, "audio.raw");
file.write(new Uint8Array(data));

// 標準FormData
const formData = new FormData();
formData.append("audio", file);

// 標準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");

// 文字列インデックスを手動で追跡
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 });
      }
    };

    // ARMアーキテクチャのみをビルド(x86/x86_64をスキップ)
    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:
      # - ".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スレッドはバックグラウンドで信頼できません — OSはバッテリーを節約するためにそれを終了することを好み、Headless JSは事実上放棄されています。

react-native-ble-plxreact-native-ble-managerを試しましたが、どちらも私たちのユースケースには十分信頼できませんでした。

そこで、BLEレイヤー全体をネイティブで書きました。