从怀疑到信服:Fieldy 如何为它的 AI 可穿戴设备采用 Expo

Fieldy 的 AI 可穿戴设备需要保持 BLE 长连接和后台音频。他们迁移到 Expo,把应用体积缩小了 25%,并实现了全流程自动部署。

中文
复制
From skeptic to convert: how Fieldy adopted Expo for their AI wearable

本文由 Adomas Valiukevičius 撰写——他是 Fieldy 的联合创始人兼工程负责人,正在打造一款 AI 可穿戴助手,帮助人们在现实世界中记住事情、整理信息并采取行动。

Fieldy,我们正在做一款 AI 可穿戴助手。App 连接一台蓝牙硬件设备,处理音频传输、生成摘要,并替你执行操作。

由于我们高度依赖 Bluetooth Low Energy(BLE)和后台进程,我们对性能格外敏感。我们持续盯着 RAM 占用和 App 体积,因为系统随时都在找理由杀掉后台任务。

说实话,我们以前从没认真考虑过 Expo。团队里没人用过。我们不是“反 Expo”,只是对它不了解。我们担心的是,在我们这些重度原生需求之上再加一层“托管”抽象,会带来体积膨胀,或者在我们需要直接碰底层的时候把我们挡住。

Expo 入门

后来我们招了 Tomasz Sapeta。我们的投资方 Inovo.vc 介绍了他,当时他正在休长假,就以兼职的方式加入,帮我们做性能优化。

他建议用 Expo。我们一开始对额外开销持怀疑态度,但我们信任他。我们把 repo 交给他,说:“放手让他做。”

下面是实际发生的事、我们拿到的数字,以及这一路上我们搞出的乱子。

审计

为将来省事而加一些监控手段,和做一次深度审计去修当下已经坏掉的东西,是两回事。我们意识到,这个“bare” App 日积月累堆了不少垃圾,必须逐行检查我们的 package.json

我们检查了每一个 package,确认它实际被用到的程度,调研替代方案,并标记出已知问题。为了让影响看得见,我们改了启动命令,默认跑 Expo Atlas。Atlas 是 Expo 自带的 bundle 分析工具——它会用可视化的方式告诉你,究竟是什么在撑大你的 JS bundle。

"scripts": {
  "start": "EXPO_ATLAS=1 APP_VARIANT=development expo start",
}
Expo Atlas

我们找到了三个白白占空间的大头。

清理 JS bundle

Atlas 立刻帮我们找出了几个体积很大的包。在一个 PR 里,我们揪出了三个冗余依赖:

react-native-calendar-events:我们只用它检查权限,而这些权限我们早就不需要了。

react-native-calendars:这是 Wix 出的一个重量级库。我们用它仅仅是为了一个日期选择器。它还顺带引入了 lodash,光这一个就占了整个 bundle 的 4%

react-native-date-picker:我们只用它做时间选择器。

三个全删了,换成一个简单的自定义日期选择器组件。JS bundle 体积少了 10%,还顺手去掉了三个原生依赖。

清理 JS 打包体积

修复 tree shaking

说到 lodash,删掉 react-native-calendars 只是解决了一半问题。Atlas 发现我们自己的代码里还背着约 700KB 的 Lodash,因为我们导入的是整个库(import { debounce } from "lodash"),而不是具体路径。

date-fns 也是同样的情况。两者都改成直接导入(比如 import debounce from "lodash/debounce")之后,bundle 体积骤降。删掉的库加上修正的导入,一共砍掉了 2MB 多的 JS bundle。

关于 tree shaking 的更多内容——https://docs.expo.dev/guides/tree-shaking/

应用从 19.8MB 降到 17.8MB:

Tree shaking 结果
更彻底的 tree shaking

删除无用字体

Atlas 在清理 JS bundle 的同时,我们手动检查了原生构建资源,发现了更大的家伙:SF-Pro.ttf 以及其他字体,加起来有 18.6 MB

SF Pro 是 iOS 的系统字体——根本不需要打包进去。而在 Android 上打包苹果的系统字体更是毫无意义。删掉。就这样,应用二进制文件小了近 5 MB。

删除未使用的字体

React Native 上的正经 fetch

在 Web 上,上传文件或流式传输数据轻而易举。在 React Native 里,这一直很痛苦,得混用各种私有库。

Expo SDK 54 让原生应用符合了 WinterCG 规范。说人话就是:原生 fetch 的行为和 Web fetch 一样了。我们终于不再用原生 workaround,开始用平台标准。

文件上传

以前光是处理 multipart 音频上传,我们就得依赖 react-native-fsRNBlobUtil。要把文件写到磁盘,用私有 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 上通知我们。

自动化部署

持续原生生成(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;
  });
};
采用 CNG

用 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 里会收到一条带状态的消息。

Slack 工作流

Expo Modules(原生的力量)

关于 Expo 常见的担忧是:用了它就被挡在原生代码之外。并不会。Expo Modules 让你在 JS 旁边写 Swift 和 Kotlin,两层之间可以干净地互相调用。

Fieldy 依赖与实体设备之间保持蓝牙长连接。JS 线程在后台并不可靠——系统为了省电随时会把它杀掉,而 Headless JS 实际上已经没人维护了。我们试过 react-native-ble-plxreact-native-ble-manager,对我们的场景来说都不够可靠。于是我们用 Expo Modules 把整个 BLE 层用原生 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
	    }
	  }
...
}
Widget

我们犯过的错

多个应用变体

当然,不全是顺利的事。我们在 OTA(Over-The-Air)更新上犯了一个严重错误。

我们使用多个应用变体(developmentproduction),Bundle ID 各不相同。RevenueCat 靠这些 ID 校验订阅。

有一次我们从本地机器用 eas update 执行了部署。问题出在哪?EAS 读的是我本地的 .env 文件,而它当时设成了 APP_VARIANT=development。我误把开发配置推到了生产 channel。

用户一更新,生产环境的应用突然开始用开发密钥去连 RevenueCat。付费墙挂了。 我们损失了收入,用户也一头雾水。

修复很简单——不再本地部署。现在所有发布都走 EAS Workflows,它会根据构建 profile 自动处理环境变量。

结语

写这篇文章的时候,很难想象不用 Expo 去构建 React Native 应用——尽管我们在生产环境里只用了九个月。这在很大程度上要归功于 Tomasz Sapeta 把我们推向了这个方向。

我写这篇文章,是想说明即便是一支非常怀疑的工程团队,也能迁移到 Expo,并最终真心享受到它带来的好处。

如果你想试试我们做的东西,Fieldy 已在 iOSAndroid 上线。

来源: Expo Blog← 返回首页