从怀疑到信服: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-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 上通知我们。

自动化部署

持续原生生成(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-plx 和 react-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)更新上犯了一个严重错误。

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

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

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

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

结语

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

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

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

来源: Expo Blog← 返回首页