Cohub Mobile 开发记:三周、31 个版本,和一个「原生质感」的客户端
先说结论
Cohub Mobile 是我们给 Cohub 做的原生 iOS / Android 客户端,用 React Native + Expo 写的。
三个数字先摆出来:
- 8 月 27 日首个版本 v1.1.0 → 9 月 18 日 v2.2.14
- 31 个版本、约 375 个提交,三周
- 仓库里没有 web target
最后一条是这个项目的设计原则,写在 README 第一段:
This repository is the native iOS/Android client. There is no web target.
翻译成工作方式就是:用 RN 写,按原生验收。 没有”先做 H5 再套壳”,界面全部是原生屏;唯一用 WebView 的地方是用户发布的网页作品——那是内容本身,本来就该是网页。
这篇讲三件事:它是什么;我们怎么搭的(技术选型和发布链);以及三周里真正花时间的那些细节。
一、它是什么
它不是一个 demo,是我们每天在用的客户端。主要能力:
- Chats:跨 Space 的会话收件箱,分页、搜索、“正在运行 / 需要注意”筛选、乐观发送、停止、重命名、附件、相机、相册,以及实时流式补丁
- 会话历史:按 turn 组织,older / newer 分页,可搜索的 turn 导航,直接跳到某个窗口,滚动位置稳定
- 模型选择器:各模型的可用状态、provider 和上下文元数据、每个模型单独的 thinking level
- 语音输入:原生 PCM 采集,直连 Cohub ASR
- Spaces:Chats / Files / Saves / Works / Task Runs
- 文件与作品:原生文件查看器;发布的 Work 用受限 WebView 打开
- Activity:活动与用量概览
- 设置:Profile / Appearance / Activity / Notifications / Rules / Channels / Billing / Referrals,全部原生
- 底座:缓存优先的 SQLite 水合、重连对账、按用户隔离的缓存清理
- 接入:Logto PKCE 登录、
cohub://深链、通知点击路由、APNs / FCM - 更新:Android 应用内下载 APK(SHA-256 校验);可选 OTA
一句话概括产品目标:把 Cohub 在网页上已有的能力,做成一个真正属于手机的版本,而不是把网页塞进手机。
二、技术选型:几个想清楚的决定
Expo 是工具链,不是托管服务。 Expo SDK 57、RN 0.86、新架构;不依赖 EAS。路由和深链用 expo-router,动画用 Reanimated 4 + gesture-handler,列表用 LegendList。
渲染要原生。 完成的消息文本走原生 Markdown 渲染;流式回复里的代码块,用一个增量 Shiki tokenizer 边流边高亮,而不是等整段结束再渲染。
状态是缓存优先的。 SQLite 水合让启动和导航立刻有内容,但服务端始终是权威——缓存不能覆盖更新的服务端状态;发送是乐观更新,回来再做对账。
边界要写死。 原生 API 只允许出现在 src/platform/ 和 src/auth/ 这些模块后面;凭证放 SecureStore;EXPO_PUBLIC_* 里绝不出现秘密。还有一条很实际的规则:浏览器里验证过,不代表真机验证过——权限、通知、语音、键盘这些,必须设备上见。
三、发布链:main push 就是上线
这部分是我们花了最多心思的地方,也最像”工程”而不是”写代码”。
main 分支的每一次 push:
- 质量门:lint、严格 TypeScript、release 元数据校验、Expo Doctor、依赖审计、三平台 JS export
- Release Please 根据 Conventional Commits 维护版本和 CHANGELOG
- 自动发 OTA:把这次 commit 的 JS 包发到 Android 和 iOS 两端的生产 channel
打 tag(vX.Y.Z)才会出原生包:
- 四个 ABI 的签名 APK(arm64-v8a / armeabi-v7a / x86 / x86_64)挂到 GitHub Release
- iOS 的 IPA 上传 TestFlight
- 同时记录两个平台各自的 OTA fingerprint
OTA 的几个细节:
- 客户端是
ON_LOAD+ 零等待:先跑缓存/内置代码,后台下载,下次冷启动生效——不会把你正在看的会话打断 runtimeVersion用 fingerprint 策略:只改 JS 不会改变 runtime,所以在已装的二进制上持续发版;一旦动到原生依赖、权限或 SDK,fingerprint 变化,必须出新包。这个约束逼我们把”哪些改动可以 OTA、哪些必须发版”分得很清楚- 每
--delta-bases 3:对最多三个兼容的历史版本生成 BSDIFF40 二进制补丁,补丁不划算就回退整包——这也是为什么我们做了 yaota 那个自托管 OTA 服务
Android 的应用内更新: About 页扫描我们自己的 APK 索引 → 按设备 ABI 下载 → 校验大小和 SHA-256 → 交给系统安装器。有两个细节我们刻意做了:关掉安装器不算更新成功,也不会 snooze 这个版本;签名不匹配(比如从调试签名切到正式签名)必须重新安装,不能用”覆盖”的方式蒙过去。
四、三周里真正花时间的细节
版本号涨得快,不是因为功能堆得多,而是因为一个细节就是一个版本。挑一些战场记录:
- 打开聊天的那一帧:发送瞬间会闪一下灰屏,原因是拿到了过期的行布局。修法是重做首帧绘制路径——进聊天时第一帧就画出目标会话。
- 流式的尾巴:快速 token 爆发时尾巴会被吃掉;中途点进正在生成的会话,“接回”流式内容也翻过车。前后修了三条。
- 气泡几何:流式期间时间戳的位置会来回跳(我们量到过 48 → 64 → 48 pt 的摆动)。最后改成测量驱动:元信息要么整块留在最后一行,要么单独起一行,并配了一份 29 组固定 case × 2 个角色的验收页。
- 代码块的手势:横向溢出的代码块左右滑,会误触发侧边面板。先让代码块”吃完”自己的拖拽,再把面板的预加载补上,解决点开时白屏和慢。
- 麦克风口述:听写文字超框但不自动增高。连修三次,最终按文本推导高度,并且在 append 的时候增长,而不是录音时。
- Android 预测性返回:我们先实现了这套返回动画,又连续修了四个边界(用受支持的 callback、收窄栈状态、转场期间加守卫、取消滑动时避免 fragment attach),最后决定把这个手势关掉。这是个不太浪漫的结论:原生能力不是每一项都值得留,收益必须大于它带来的复杂度。
- landing 页的闪烁:滚动时元素一闪一闪,停下 scroll reveal 的”闪入”动画。
- 崩溃与诊断:用户反馈了一次 iOS 崩溃并附了文件,此后我们把诊断做成了一等公民——本地导出诊断日志、卡死后导出崩溃会话、SQLite 反馈收集、聊天滚动诊断。先让问题可观测,再谈修。
- 性能:“为啥感觉这个 APP 性能很垃圾”是从用户嘴里直接进来的。查的过程中有个有意思的发现:Markdown 解析在 V8 里其实很便宜(4000 字符的富文本约 0.35 ms),所以问题不在 parse,而在渲染路径。之后列表迁到 LegendList,并约束打开聊天时的代码工作量。
这些修复在 CHANGELOG 里都只有一行,但每一行背后都是一次设备上的观察、一次定位、一次验证。
五、验证:把”细节”变成可重复的东西
要让”原生质感”不变成一句口号,得把它变成能反复执行的检查:
- Bubble Layout Acceptance:29 组固定 case × 2 个角色(用户 / 助手)+ 12 档边界宽度 + 280 pt 窄视口 + 深色/浅色 + 流式的 start / pause / interrupt。判定标准写得很直白:元信息允许整块在最后一行或整块在下一行,但不允许盖住字符、链接和状态图标。
- E2E 跑在 CI 的设备上:Android 设备流跟在 Publish OTA 之后跑。为了让它稳定,我们踩了一串坑——镜像里要先开 KVM 才能起模拟器,logcat 要流式落盘而不是攒在内存里,没有配置 E2E 账号时干脆跳过。现在还有几条流程在修(能做到 9/13 就已经比”靠手感”强太多)。
- 诊断导出:用户和我们都能把会话日志、崩溃现场导出来核对。
六、我们是怎么开发的
这个项目的开发日志就写在 Cohub 的 space 里——三个星期待下来攒了 20 个 session,标题基本就是每天的现场:
“为啥感觉当前这个 APP 性能很垃圾” “chat 页面左右滑动召唤列表白屏 加载慢” “iOS 端 Chat 页面回退按钮失效问题” “麦克风输入文字超框无自动扩大问题” “安卓原生应用预测性返回是什么及具体表现,实现其动画” “讨论当前项目的 E2E 方案及边境情况” “优化对话的草稿保存与新对话创建逻辑”
流程很朴素:用户(包括我自己)在真机上发现问题 → 进 space 描述现象、贴日志 → agent 参与定位、改代码、补验证 → 进版本 → main push 直接 OTA 到设备。发现问题到设备上生效,经常是当天。
三周 31 个版本的意思是:平均每天 1.4 个版本。能做到这个节奏,靠的不是”写代码快”,而是前面那套发布链——main push 即上线,原生包只在需要时出。
最后
如果你只看前面几段,可能会觉得这是一个”RN 也能做原生”的样板。我更想说另一句:
原生质感不是选出来的,是验出来的。
选 RN 还是原生,只是决定了你要打磨的对象和成本;决定体验的,是愿不愿意为一个 48pt 的抖动、一次灰屏、一个误触手势停下来,量一量、改一改、再验收一遍。
Cohub Mobile 还在快速迭代里,下载地址和源码都在这:
- 源码:github.com/markbang/cohub-mobile
- Android:GitHub Release 里按 ABI 下载安装包,应用内也能检查更新
- iOS:TestFlight 内测
- 配套:yaota(自托管 Expo OTA)
(space 里的描述写的是 “cohub mobile: native for all”。就照这个干。)