如何用 Expo 构建一个韧性十足的活动追踪器
如何用 TaskManager、Expo Modules 和卡尔曼 GPS 滤波器打造一个步行追踪器,让它扛得住应用被杀、GPS 噪声和后台终止。
中文
复制

本文作者 Daniel Vinojčić,是 Calda 的软件工程师。
这个功能听起来很简单:用户开启一次步行会话,记录步数、距离和时间,走到目的地,扫一个二维码结束。点开始、走路、扫码,完事。难的是中间发生的所有事情。一次会话最长可以持续一个小时,期间用户会切换应用、锁屏、接个电话。一旦这么做,操作系统随时可以毫无预警地杀掉你的进程。如果会话状态存在内存里,它就没了,用户的进度也没了。
所以我们需要一种会话,能在切到后台、应用被杀、崩溃之后依然存活,同时又不脱离 Expo 的工作流。下面是我们怎么做到的。
挑战:在移动端追踪活动
我们最初的实现把会话状态放在内存里。应用留在前台时没问题,但有三个地方会崩:
-
无法后台执行: JavaScript 的执行绑定在应用的生命周期上。用户一切走,操作系统随时可以挂起或终止进程,没有预警,也没有优雅退出。
-
GPS 噪声与漂移: 原始 GPS 数据不可靠。静止不动的用户会因为信号漂移累积出虚假距离,坐公交的用户则会被记成走了好几公里。
-
没有崩溃恢复: 如果应用在会话中途被杀,走了三十分钟的记录就这么没了。
目标:一个杀不死的会话
我们需要三件事协同工作:
-
后台追踪,应用不在前台也能持续运行。
-
GPS 过滤,得出准确的步行距离,而不是车辆行驶距离。
-
持久化与恢复,在被杀时保住状态,并在重新启动后还原。
用 TaskManager 做后台定位追踪
Expo 的 TaskManager 让你定义在 React 组件树之外执行的任务。配合 expo-location,我们在模块层级注册一个后台任务,即使应用被挂起也能收到 GPS 更新。
这是后台追踪任务的核心部分:
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
const BACKGROUND_LOCATION_TASK = 'background-location-task';
const FALLBACK_ACCURACY_METERS = 30;
const gpsFilter = new GPSFilter();
const distance = new DistanceAccumulator();
let seededSessionId: string | null = null;
TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }) => {
if (error) return;
const { locations } = (data ?? {}) as { locations?: Location.LocationObject[] };
if (!locations?.length) return;
const state = await loadActivityState();
if (!state) return;
if (seededSessionId !== state.sessionId) {
seededSessionId = state.sessionId;
distance.restore(Math.max(distance.totalKm, state.distanceKm ?? 0));
}
for (const raw of [...locations].sort((a, b) => a.timestamp - b.timestamp)) {
const filtered = gpsFilter.process(
{
latitude: raw.coords.latitude,
longitude: raw.coords.longitude,
accuracy: raw.coords.accuracy ?? FALLBACK_ACCURACY_METERS,
speed: raw.coords.speed,
timestamp: raw.timestamp,
},
state.activityPausedAt != null
);
if (!filtered.accepted) continue;
const speed = filtered.dopplerSpeedMs ?? filtered.speedMs;
if (speed != null) {
distance.addVelocitySample(speed, filtered.dtSeconds ?? 0);
}
}
await updateActivityState({
sessionId: state.sessionId,
distanceKm: distance.totalKm,
elapsedSeconds: deriveElapsedSeconds(state),
});
});
这里没有 React:没有 useState,没有 hooks,没有组件树。任务直接读写存储,而新进程在第一次 tick 时信任的是持久化的快照,而不是模块状态。
循环会遍历每一个已投递的定位点,从最早的开始,而不是只取最新的:手机被锁屏时定位会批量投递,只取最新的一条会丢掉中间走过的距离,让滤波器缺少测量值。
重新播种才是关键。当系统在内存压力下回收进程,之后又重启服务来投递一批数据时,JS bundle 会从头重新执行,所有模块级累加器都归零。没有这一步,会话并不会丢掉已存储的总量(写入路径是单调的,后面会讲到),但会变成一条平线:进程被杀之后走过的每一米都被丢弃,直到新的累加器重新爬过旧值。有一点需要说明是哪种杀进程——如果是用户主动终止应用,后台定位就会停止,Android 也不会因为定位事件把它重新拉起。这段空缺无法恢复,所以下一个选项才重要。
在 iOS 上,我们设置 activityType: ActivityType.Fitness 来获得优化后的 GPS 行为。在 Android 上,前台服务通知让任务保持存活——这里有两个容易搞错的选项:
await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
accuracy: Location.Accuracy.High,
timeInterval: 5000,
distanceInterval: 0,
foregroundService: {
notificationTitle: 'Activity in progress',
notificationBody: 'Tracking your walk…',
killServiceOnDestroy: false,
},
activityType: Location.ActivityType.Fitness,
showsBackgroundLocationIndicator: true,
pausesUpdatesAutomatically: false,
});
killServiceOnDestroy: false 不是可有可无的。没有它,“前台服务让我们活着”这句话在用户把应用划掉之前都成立,之后就不声不响地不成立了。
distanceInterval 是更隐蔽的陷阱。看起来“合理”的 10 米像是一种优化,但在 Android 上它是对 provider 做过滤,而不是给它提示:采样率会掉到原来的三分之一甚至四分之一,偏偏是在屏幕关闭、定位最粗的时候。距离是对时间做积分,所以稳定的节奏胜过剔除近似重复的点。
关于权限还要说一句,因为在这套技术栈上它们很容易被低估。iOS 后台追踪需要通过 requestBackgroundPermissionsAsync 获得 Always 授权,而不只是 When In Use。Android 需要 ACCESS_BACKGROUND_LOCATION,并且从 Android 14 起还需要 FOREGROUND_SERVICE_LOCATION。expo-location 会自动加上 manifest 条目,但 Google Play 对后台定位和前台服务都要求提交书面说明并经过审核,应用才能上架,所以要把审核时间算进去,而不只是写代码的时间。
权限也不是唯一一种「已授予」却仍然无法定位的情况。设备级定位服务关闭、但每个应用的权限都完好时,startLocationUpdatesAsync 依然会成功,通知依然会弹出,却永远等不到一个定位点。开始记录之前,值得单独检查一下 Location.hasServicesEnabledAsync()。
用卡尔曼滤波过滤 GPS
早期版本用的是 3 米阈值:低于 3 米的位移一律忽略。这能对付静止漂移,却解决不了真正的线上问题:坐公交和开车的用户会攒出几公里的「步行」距离。有个用户一天只走了 8,600 步,却记录了 128 公里。
我们把它换成了一组相互独立的闸门,和健身类应用把抖动的定位点变成可信轨迹用的是同一套技术:
精度闸门:expo-location 每次更新都会以米为单位报告精度。超过 30 米的一律丢弃,精度非正数的也丢弃(iOS 对无效定位会报告 -1)。大多数实现都忽略了这个字段,白白浪费了信息。
速度闸门: 速度超过 8 m/s(约 29 km/h)就丢掉这个点——仅这一条就消除了大部分车辆带来的距离虚增。问题在于,coords.speed 在许多 Android 融合定位中是 null,在 iOS 上无效时是 -1,而负的哨兵值比缺失更糟:每一次 speed < threshold 检查都会把它默默读成静止不动。平台给了值且非负就用它,否则用位移除以时间推算速度。记住你拿到的是哪一种——一个是多普勒测量,另一个不是,这一点在下文很关键。
离群点闸门: 把每个定位点和滤波器预测的位置做比较。多径造成的「瞬移」尖峰即使自称精度良好也会被丢弃;连续三次被拒说明是真实的位移(GPS 中断、出隧道),这时我们重新锚定,而不是永久锁死。
卡尔曼平滑: 被接受的点在每个坐标轴上过一个匀速卡尔曼滤波器,在本地米制投影下运行,而不是直接在原始经纬度上跑(一度经度并不是固定距离,不过在城区尺度上这点近似几乎无所谓)。把各轴独立处理忽略了轴间相关性,但对行人轨迹平滑来说这个简化站得住。滤波器用估计速度预测下一个位置,再用实际读数按报告的精度加权修正。
然后是大多数实现都会做错的一步,我们最初也不例外:如何把过滤后的位置变成距离。
最直观的做法,是把相邻两个被接受的点位之间的距离累加起来,而它自带一个正向偏差。|Δposition| 是一个范数,永远不会为负,而滤波残留的抖动——即便过了 Kalman 也始终存在——会像随机游走的路径长度那样不断累积,而净位移始终在零附近。设一个最小位移阈值并不能解决这个问题,它只是顺带把真实的缓慢移动也一起丢掉了。
我们改为对速度做时间积分——distance += speed × dt,带 0.3 m/s 的死区,让噪声不贡献任何东西,上限 8 m/s,并对 dt 做 10 秒的钳制:
addVelocitySample(speedMs: number, dtSeconds: number): number {
if (speedMs < MIN_INTEGRATION_SPEED_MS) return this.accumulatedKm;
const speed = Math.min(speedMs, MAX_WALKING_SPEED_MS);
const dt = Math.min(Math.max(dtSeconds, 0), MAX_PREDICT_DT_S);
this.accumulatedKm += (speed * dt) / 1000;
return this.accumulatedKm;
}
积分哪个速度,比积分本身更重要。滤波器的估计来自位置,因此继承了位置的误差,而 hypot(vN, vE) 是两个不确定分量的模——这种不确定性只会把数值往上推,绝不会往下压,并且随定位精度的下降而增大。在 Android 息屏时返回的 25–30 m 定位精度下,对于一个慢速行走的人来说,它已经和整个信号量级相当。
coords.speed 是更好的数值,而且是免费的:GNSS 从载波频移推导它,而不是对位置做差分,因此既不继承位置误差,也不继承多径漂移。大多数实现只拿它来剔除车辆。它才是更该被积分的量,所以上面的任务优先用 dopplerSpeedMs,只有在操作系统不提供它时才回退到滤波器。
还有两道闸门跑在滤波器之外,因为一条平滑过的轨迹可以很平滑,却依然是错的。净位移检查问的是设备在一个滑动窗口内究竟有没有移动过——路径长度会被每一次抖动抬高,净位移不会——阈值随定位精度变粗而放大。在 Android 上,运动活动闸门会在设备未被判定为处于行进状态时抑制距离;Activity Recognition 基于加速度计和陀螺仪工作,所以多径骗不过它,而基于位置的检查会被骗过。
expo-location 现在提供 getMotionActivityAsync 和 watchMotionActivityAsync,用同一套平台 API 封装,并带有按类型区分的置信度——写原生代码之前先用它们。我们自己那套还留着,是因为计步器在 Kotlin 模块内部就原生决定了是否发出传感器事件,所以这个订阅无论如何都得存在于那里;再从 JS 读一遍等于跑两份。
三层持久化
你可能会想在这里只留一个数据源:写进存储,收工。但每种故障模式需要的层不一样,所以我们跑了三层:
| 层 | 存储 | 能扛住什么 | 写入频率 |
|---|---|---|---|
| 1 | Zustand(内存) | 什么都扛不住,它就是 RAM | 实时 |
| 2 | AsyncStorage | App 被杀、崩溃 | 每 30 秒 + 切到后台时 |
| 3 | Supabase | 手机丢失、重装 | 每 30 秒 + 切到后台时 |
最关键的时刻是 AppState 切到后台,这是系统可能杀掉我们之前最后一次写入的机会:
AppState.addEventListener('change', async (nextState) => {
const state = useActivityStore.getState();
if (state.phase !== 'running' && state.phase !== 'paused') return;
if (nextState === 'inactive') {
await state.persistToStorage();
}
if (nextState === 'background') {
await state.persistToStorage();
await flushRemoteProgress();
}
if (nextState === 'active') {
const persisted = await loadActivityState();
if (persisted && persisted.distanceKm > state.distanceKm) {
state.updateDistance(persisted.distanceKm);
}
}
});
两个写入方,一个 key
下面这个故障模式花掉了我们最多的调试时间,而且它不是系统杀进程。后台任务和前台 store 持久化的是同一份快照,而两边各自只掌握一部分真相:GPS 距离在任务里,步数在前台监听器里。从任意一边写入整份快照,都会把另一边刚写的东西回滚掉——我们当时看到的现象是已暂停的活动自己翻回运行中,原因是一次后台写入读到的状态早于暂停落地。
两条规则能解决:对同一个 key 的每次读-改-写都串行化,以及永远不要覆盖——要合并,对任何在一次会话中不能倒退的值取较大者:
function mergeMonotonic(current: PersistedActivityState, patch: Partial<PersistedActivityState>) {
const merged = { ...current, ...patch };
if (patch.distanceKm != null) merged.distanceKm = Math.max(current.distanceKm ?? 0, patch.distanceKm);
if (patch.steps != null) merged.steps = Math.max(current.steps ?? 0, patch.steps);
return merged;
}
远端那一层也需要同样处理:30 秒一次的自动保存必须排队,否则更新的写入可能后落地,把你排行榜读到的数字压低。普通的 UPDATE 是后写者赢;GREATEST(...) RPC 不是。单调写入也是后台任务冷启动安全而非破坏性的原因——一个归零的累加器抹不掉已存储的总量,它只能推进不了,而这正是重新播种所堵住的那个故障。
有一点需要坦白说明:在 iOS 上,从被彻底杀死的应用中读取持久化数据,是边缘情况最多的一条路。AsyncStorage 有很长的历史问题——当进程已被终止时,在无界面的后台任务中读取会返回 null。应用只是进入后台时它工作可靠,但被杀死后重新启动的场景,恰恰是整套架构所依赖的那一个。测试时要从应用切换器中强制退出应用,而不只是切到后台。如果你真遇到了这个问题,对于后台任务在冷启动时必须读取的数据,expo-sqlite 或 expo-file-system 是更可靠的存储方案。
重新启动后的恢复
应用启动时,我们会检查存储中是否存在孤立的会话,并与后端进行校验。微妙之处在于,当网络不可用时,“校验”到底意味着什么:
async function initRecovery() {
const persisted = await loadActivityState();
if (!persisted) return;
const { status, session } = await lookupSessionById(persisted.sessionId);
const canRecover =
status === 'unreachable' || (status === 'found' && session != null && !session.ended_at);
if (canRecover) {
setRecoveryData(persisted);
setIsRecovering(true);
} else {
await clearActivityState();
}
}
三态状态才是关键。显而易见的实现会返回一个可为空的会话,这就把“服务器说这个会话不存在”和“我们无法连接到服务器”折叠成了同一个值——于是用户每次离线重新启动时,行走记录都会被丢弃,而这正是恢复机制存在的场景之一。无论你用的是哪种后端,真正的空结果和请求失败到达的方式是不同的;把它们区分开,在完全不确定时默认采用本地快照。
如果会话可以恢复,用户会看到一个提示:继续或丢弃。继续会从持久化的快照中恢复 Zustand store,还原累计距离,以 resume 模式重新启动后台定位追踪(普通的启动会把累加器清零,下一个 GPS 回调就会持久化 0 km),并在锁屏上重新启动 iOS Live Activity。
当本地状态与服务器状态不一致时,以服务器的 ended_at 为准:如果后端说会话已经关闭,我们就丢弃本地副本,而不是复活一个已结束的会话。
还有一样东西需要重启,而且很容易被遗忘:定位任务本身。激进的 OEM 电池管理可能会杀死你的前台服务,却不杀死你的应用。没有任何东西会自动重新注册这个任务,于是会话剩下的部分就会悄无声息地记录 0 km。每次回到前台时,我们都会检查任务是否仍然注册,如果没有,就以 resume 模式重新启动它。
跨平台计步
计步是 iOS 与 Android 分歧最彻底的地方。在 iOS 上,expo-sensors 暴露了一套基于 Core Motion 的高层 Pedometer API。我们在当前步数分段窗口内轮询 getStepCountAsync()。由于 Core Motion 在系统层面记录步数,这个查询会返回即使我们的应用处于后台期间累积的步数。
Android 没有这样的捷径。getStepCountAsync() 是 iOS 独有的,而且两个平台都不会在后台推送计步器更新。Expo 的文档建议 Android 用户使用 Health Connect,这值得优先评估——但我们需要的是按会话的实时计数,以及能挺过进程被杀死的基线,所以我们直接用了传感器。
我们用 Kotlin 写了一个自定义 Expo Module 封装 SensorManager,遵循 Android 推荐的做法。它先尝试 STEP_COUNTER(累计值,只在重启时清零,但读数可能延迟数秒批量上报),失败则回退到 STEP_DETECTOR。两者在 Android 10+ 上都需要 ACTIVITY_RECOGNITION 运行时权限。我们还通过 ActivityRecognitionClient 以 Google Play 的 Activity Recognition API 作为上报的前置条件,这样当设备被判定为静止或处于车辆中时,步数就不会被计入。
一个 hook 抽象掉了平台差异。注意它的写法——这里又是一个会被异步和清理逻辑咬到的地方:
function startAndroidStepTracking(): () => void {
let isActive = true;
const removeListener = stepTracker.addStepListener(onSteps);
void (async () => {
const started = await stepTracker.startTracking();
if (!isActive && started) await stepTracker.stopTracking().catch(() => {});
})();
return () => {
isActive = false;
removeListener();
void stepTracker.stopTracking();
};
}
两个平台的 start 函数都返回同步的清理函数。把 async 函数的结果从 useEffect 返回,等于交给 React 一个永远不会被调用的 Promise,原生监听器会在组件卸载后继续存活——这个泄漏表现为步数不断累积进一个用户早已结束的会话。
实时监听器和内存中的状态有同样的弱点:它只在你进程存活时计数,所以在激进的 OEM 定制系统上,一次锁屏 20 分钟的步行会被悄悄少算。STEP_COUNTER 解决了这个问题——自开机起累计,由传感器 hub 维护,与你的进程无关。我们在会话的第一个事件时记录它的值作为基线,之后每次回到前台都取差值,并以 GPS 管线实际检测到步行速度移动的时间为上限,这样未经过滤的计数器就无法虚增总数。
结果
这套系统已经在生产环境运行了一段时间,真实用户在城市中完成了步行记录。
-
没有丢过 session。 持久化、单调合并和恢复这几块做完之后,再没出现过。
-
距离统计准确。 先用精度和速度做门控,再对速度积分,而不是累加位置差,车辆造成的距离虚高和慢速下的幽灵累积都消失了——之前每天记录 128 km 的那位用户,现在显示的是正常的步行数据。
-
距离与专业记录设备一致。 对平台的 Doppler 速度积分,而不是用位置反推速度,这才补上了慢速步行时的差距——位置反推的速度恰恰在慢速时最不可靠。和其他记录设备并排走同一段路,现在得到的距离几乎相同。
-
恢复干净。 切后台、杀进程、崩溃,在线离线都能扛住。用户回到应用时,接着上次的位置继续。
还可能在哪里失败
韧性指的是进程被杀之后还能活下来,而不是让它不被杀,况且操作系统也不总是允许你一直运行。在 Android 上,原生系统属于简单情况,变数来自各家 OEM 的电池管理。Xiaomi/MIUI、Huawei/EMUI、Samsung 的 One UI、Oppo、Vivo 都自带激进的任务清理机制,即使你该做的都做了,前台服务照样可能被停掉(dontkillmyapp.com 按厂商记录了这些行为,Expo 的后台文档也链到了那里)。我们的缓解手段是常驻的前台服务通知、killServiceOnDestroy: false、在必要机型上提示用户关闭电池优化,以及前台自愈检查——但在这些设备上,我们只把持续运行当作尽力而为。Doze 是同一压力的温和版本,原生 Android 上都有。
iOS 更可预测,但也不是没有限制:长 session 仍可能被挂起,而且从 iOS 16.4 起,你必须把持续更新配置正确才能保持存活:kCLDistanceFilterNone,精度约 100 m 或更好,或者开启后台指示器。精度再粗,更新就会悄悄停掉。
距离还有一个残留限制值得说明。慢速行走时的精度依赖平台提供多普勒速度;如果平台不提供,我们就退回到由位置推导的速度,而它的不确定性会随定位精度的变化而增大。门限只是约束了这一点,并没有消除它。要进一步收紧,就得把步频与经 GPS 校准的步幅融合起来,也就是专用健身硬件的做法。
所以我们不靠“活着”取胜。当系统把我们切断时,持久化层和恢复提示把“被杀掉”变成“可恢复”——这才是关键所在。
这对 Expo 意味着什么
要做一个有韧性的活动追踪器,需要后台任务、原生传感器 API、实时小组件,以及跨两个平台的多层持久化。四年前,这意味着必须从 Expo 中 eject。现在显然不是了。
TaskManager 让我们的 GPS 追踪在应用生命周期之外运行。Expo Modules API 让我们用 Kotlin 写原生 Android 计步器,而不必离开 Expo 的工作流。至于 Live Activity,我们用 @bacons/apple-targets 通过 Continuous Native Generation 把小组件扩展生成为原生 Apple target,于是 ActivityKit 代码放在 /ios 之外,每次 prebuild 都能存活——而且这条路今天比我们当初走的时候更短:从 SDK 56 起,iOS 小组件和 Live Activity 已在 expo-widgets 中稳定。EAS Build 负责多 target 编译(主应用和小组件扩展),无需手动配置 Xcode。
这套架构归结为一条原则:不要信任进程。 应用会被杀,GPS 会撒谎,网络会断,你自己的两个写入方会互相竞争。每一层(后台任务、GPS 过滤、单调的本地持久化、单调的远程同步、恢复)都独立工作,所以任何一层失效,其余各层仍能保住用户的进度。