从怀疑到信服:Fieldy 如何为它的 AI 可穿戴设备采用 Expo
Fieldy 的 AI 可穿戴设备需要保持 BLE 长连接和后台音频。他们迁移到 Expo,把应用体积缩小了 25%,并实现了全流程自动部署。
中文
复制

本文由 Adomas Valiukevičius 撰写——他是 Fieldy 的联合创始人兼工程负责人,正在打造一款 AI 可穿戴助手,帮助人们在现实世界中记住事情、整理信息并采取行动。
…
在 Fieldy,我们正在做一款 AI 可穿戴助手。App 连接一台蓝牙硬件设备,处理音频传输、生成摘要,并替你执行操作。
由于我们高度依赖 Bluetooth Low Energy(BLE)和后台进程,我们对性能格外敏感。我们持续盯着 RAM 占用和 App 体积,因为系统随时都在找理由杀掉后台任务。
说实话,我们以前从没认真考虑过 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",
}

我们找到了三个白白占空间的大头。
清理 JS bundle
Atlas 立刻帮我们找出了几个体积很大的包。在一个 PR 里,我们揪出了三个冗余依赖:
react-native-calendar-events:我们只用它检查权限,而这些权限我们早就不需要了。
react-native-calendars:这是 Wix 出的一个重量级库。我们用它仅仅是为了一个日期选择器。它还顺带引入了 lodash,光这一个就占了整个 bundle 的 4%。
react-native-date-picker:我们只用它做时间选择器。
三个全删了,换成一个简单的自定义日期选择器组件。JS bundle 体积少了 10%,还顺手去掉了三个原生依赖。

修复 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:


删除无用字体
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;
});
};

用 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,两层之间可以干净地互相调用。
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
}
}
...
}

我们犯过的错
多个应用变体
当然,不全是顺利的事。我们在 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,并最终真心享受到它带来的好处。