Anarlog 移动端架构解析:基于 Expo SDK 57、UniFFI SQLite 与实时转写流水线的本地优先应用
2026/9/16 20:51:09 网站建设 项目流程

Anarlog 移动端架构解析:基于 Expo SDK 57、UniFFI SQLite 与实时转写流水线的本地优先应用

【免费下载链接】anarlogOpen source Granola AI Alternative项目地址: https://gitcode.com/GitHub_Trending/hy/anarlog

导读

本文以仓库文档 apps/mobile/AGENTS.md(由 apps/mobile/CLAUDE.md 通过@AGENTS.md显式委托引用)为核心,系统讲解 Anarlog 移动端(Android/iOS)的工程架构与实践约定。该移动端是 Anarlog 桌面端的移动伴侣:采用 Local-first(本地优先)数据模型,把"录音 → 实时转写 → 笔记"的完整链路搬到手机上,并通过云端同步与桌面端保持一致。读完本文,你将掌握:移动端开发命令与构建流程、SQLite 数据库层如何通过 UniFFI 桥接 Rust 核心、认证与 Pro 计费门禁的实现方式、录音与转写的实时/批处理双通道设计,以及 E2EE 云同步的接入原理。

一、项目定位与技术栈概览

Anarlog Mobile 是 Anarlog(开源的 Granola AI 替代品)的移动端应用,技术选型为Expo SDK 57(React Native 0.86.3 + React 19.2.3),使用 Expo Router 组织页面。移动端不是桌面的简单移植,而是"本地优先"的独立客户端:即使用户未登录、不订阅任何付费计划,也能使用本地笔记与本地录音功能;云同步与 Anarlog 托管的 AI 模型能力则通过三周共享 Pro 试用期与付费 Pro 权益解锁(详见 apps/mobile/AGENTS.md 的 Architecture 一节)。

从 apps/mobile/package.json 的依赖列表可以直观看到其技术分层:

  • Expo 生态expo-audio(录音)、expo-file-system(文件读写)、expo-secure-store/expo-local-authentication(密钥与应用锁)、expo-router(路由)、expo-dev-client(开发调试)、expo-widgets(iOS 小组件)、expo-sharing等;
  • 工作区包@anlg/db-runtime@anlg/db-react(数据库响应式查询)、@anlg/mobile-bridge(调用 Rust 桥)、@anlg/supabase@anlg/provider-validation@anlg/user-error等;
  • 服务端@supabase/supabase-js(认证)与@sentry/react-native(错误上报)。

多环境变体配置见 apps/mobile/app.config.ts:通过APP_VARIANT环境变量区分dev/staging/stable三套 App 名称、图标、URL scheme(anarlog-dev/anarlog-staging/anarlog)与 bundle identifier,并约定默认 API 地址(开发态http://localhost:3001,正式态https://api.anarlog.so),未设置SUPABASE_URL时则进入免认证的本地开发模式。

二、开发命令与构建流程

2.1 常用命令

AGENTS.md 明确指出两条核心命令(见 apps/mobile/AGENTS.md Commands 一节):

# 启动 iOS 开发(或替换为 android) pnpm -F @anlg/mobile ios # 类型检查 pnpm -F @anlg/mobile typecheck

两条命令都通过 workspace 过滤符-F @anlg/mobile精确作用于移动端包。其中ios/android脚本内部使用 dotenvx 从仓库根目录的.env.supabase加载 Supabase 环境变量(--ignore MISSING_ENV_FILE表示文件缺失时不报错),再以expo start --dev-client方式启动,因此依赖 Expo Dev Client 运行。

2.2 构建、测试与版本管理

apps/mobile/package.json 中值得关注的更多脚本:

脚本作用
ios:build/android:build先执行cargo xtask mobile-bridge ios(或android)重新编译 Rust 桥接层,再以expo run:ios/expo run:android进行原生构建
test使用 Node 内置 test runner 与--experimental-strip-types直接运行 TypeScript 测试
test:billing-handoff专门验证桌面→移动端的计费交接逻辑(src/auth/billing-handoff.test.mjs)
test:transcription-limits验证转写配额边界(src/data/transcription-limits.test.mjs)
typechecktsc --noEmit全量类型检查

AGENTS.md 还强调了一个容易踩坑的版本规则:移动端市场版本号独立存放于 apps/mobile/release-version.json,与桌面的release-version.json互不影响,升级时必须显式执行:

node scripts/release-version.mjs --mobile <major.minor.patch>

app.config.ts会读取该文件并把version注入 ExpoConfig 的ios.version/android.version,确保商店版本与构建产物一致。

三、总体架构:本地优先 + 云端同步

AGENTS.md 的 Architecture 一节给出了移动端的骨架,可归纳为四层:

React Native UI(expo-router 页面) │ useLiveQuery(响应式 SQL 查询) ▼ @anlg/db-react ──► @anlg/db-runtime(LiveQueryClient / TransactionClient 契约) │ ▼ src/db/client.ts(mobile-bridge 调用封装) │ UniFFI 桥 ▼ crates/mobile-bridge(Rust)──► SQLite(canonical schema,由 crates/db-app 维护)

关键事实如下:

  • 数据库契约层src/db/通过 UniFFIcrates/mobile-bridge传输层实现@anlg/db-runtimeLiveQueryClient/TransactionClient两个契约,最终由@anlg/db-reactuseLiveQuery消费。契约的具体实现见 src/db/client.ts:mobileLiveQueryClientexecute+subscribe)与mobileTransactionClientexecuteTransaction)在文件末尾导出,而 src/db/index.ts 通过createUseLiveQuery(liveQueryClient)生成移动端专属的useLiveQuery,业务页面直接引用。
  • Schema 归属:canonical SQLite schema 与全部迁移由crates/db-app统一维护,移动端与桌面共用同一套表结构与迁移脚本,不另起炉灶。
  • 数据语义对齐桌面src/data/严格镜像桌面的查询语义——canonical 的"创建会话"事务、以 ProseMirror JSON 存储的笔记文档(约定note.id == session_id)、以及session-audio:<sessionId>形式的附件行,全部与桌面端一一对应。
  • 会话 SQL 参考基准:AGENTS.md 特别指出,会话相关 SQL 必须与桌面端 apps/desktop/src/session/queries.ts 保持语义一致,它是 session SQL 的权威参考。

src/db/client.tsgetBridge()实现中可以看到底层细节:数据库文件固定为文档目录下SQLite/anarlog.db,通过MobileDbBridge.open(databasePath, "disabled")打开(第二个参数为加密相关配置,此处禁用),随后调用configureAttachmentStorage将附件存储指向文档目录与缓存目录——这正是"本地优先"物理落盘的基础。

四、数据库层深入:响应式查询与事务

4.1 LiveQueryClient 与实时刷新

移动端 UI 的"数据自动刷新"完全依赖 LiveQuery 机制。src/db/client.ts中的subscribe实现(src/db/client.ts)展示了核心模式:

  • 把 SQL 与 JSON 序列化参数下发给 Rust 桥(getBridge().subscribe(sql, params, listener)),获得一个订阅 ID;
  • 桥层回调listener.onResult/onError,JS 侧对结果行做JSON.parse后交给options.onData
  • 返回的Unsubscribe函数负责幂等退订,且所有异常路径都会进入captureOperationalError错误上报通道。

这样,当转写结果、笔记正文、录音状态等底层表发生变化时,订阅的页面会收到增量行集并自动重渲染,无需手动刷新。

4.2 事务执行与批量语句

executeTransaction接收TransactionStatement[](一组 SQL + 参数),整体序列化后一次性交给 Rust 桥原子执行。这在"落库转写结果"这类多表写操作中尤其重要:例如 src/data/transcribe.ts 在批处理转写成功时,把"软删除旧 transcripts 行 → 插入新 transcript 行 → 标记附件transcript_status=complete"三步放进同一个事务,保证用户在任何时刻都不会看到半成品状态。

五、认证、计费门禁与桌面交接

5.1 Supabase 认证与本地会话

移动端认证基于@supabase/supabase-js,见 src/auth/client.ts:

  • 使用AsyncStorage持久化会话(storageKey 根据 Supabase URL 的主机名动态生成,例如sb-xxx-auth-token),并开启autoRefreshToken/persistSession
  • 监听AppState:App 回到前台时startAutoRefresh(),退到后台时stopAutoRefresh(),避免后台空转刷新 token;
  • 无 Supabase 环境变量时supabase客户端为null,即 AGENTS.md 所述的 bypass 模式——本地开发、不做任何计费门禁。

5.2 桌面浏览器交接(browser handoff)

登录流程支持桌面端交接:通过/auth?flow=desktop&scheme=anarlog深链把桌面会话带到移动端(scheme 与 app.config.ts 中的变体配置一致),实现"桌面已登录、手机免输入"的体验。

5.3 JWT Claims 计费门禁(Hermes 限制下的移植)

Pro 权益判定采用与桌面端 packages/supabase/src/billing.ts 完全相同的 JWT-claims 逻辑,但由于jose 库无法在 Hermes 引擎上运行,移动端在 src/auth/billing.ts 中手动移植了解码与判定逻辑,并保持语义同步。要点:

  • decodeJwtPayload仅做展示/门禁用非安全解码(源码注释明确 "display/gating only, never security"),从 JWT 的 payload 段提取entitlementssubscription_statustrial_end等字段;
  • deriveBillingInfo综合订阅状态与试用期剩余天数计算planfree/trial/pro),并规定试用时钟到期与暂停订阅会覆盖过期的 entitlements,让所有客户端统一 fail closed(src/auth/billing.ts);
  • 未授权使用 Anarlog 托管模型时抛出ProRequiredError,提示用户可在设置中选择自带 API Key 的 BYOK 提供商继续使用(src/auth/billing.ts)。转写流程中捕获该错误后会把状态复位为idle,不进入失败重试(见 src/data/transcribe.ts)。

六、录音与转写流水线

6.1 录音:PCM 流 → WAV 落盘

录音入口是 src/audio/use-session-recorder.ts,基于expo-audiouseAudioStream,固定16kHz、单声道、int16 PCM(文件顶部常量STREAM_SAMPLE_RATE = 16_000STREAM_CHANNELS = 1):

  • 每个音频缓冲到达时,先由SessionWavWriter.append写入 WAV 文件(路径为<documents>/sessions/<sessionId>/audio.wav),同时计算pcmAmplitude驱动录音振幅 UI;
  • 启动前依次申请麦克风权限;Android 13+ 还需申请POST_NOTIFICATIONS通知权限(用于常驻通知);随后通过setAudioModeAsync开启allowsRecording+allowsBackgroundRecording,保证锁屏/后台可继续录音;
  • 失败类型被枚举为permission_deniednotification_permission_deniedstart_failedmedia_services_resetnative_errorsave_failed六类(src/audio/use-session-recorder.ts),便于精准上报与 UI 提示。

录音完成后,catalogSessionAudio会把该音频登记为session-audio:<sessionId>附件行(src/data/audio-catalog.ts),进入统一的数据目录。

6.2 转写双通道:实时(live_capture)与批处理(batch_transcription)

AGENTS.md 明确了移动端没有设备端 STT 模型,转写全部走云端或 BYOK:

  • 实时转写:录音过程中 PCM 数据同时流式发送到 Anarlog Pro 或受支持的 BYOK 实时模型。实现见 src/data/live-transcription.ts:连接${env.apiUrl}/stt/listen的 WebSocket(自动切换wss:协议),携带provider=anarlogmodel=cloudencoding=linear16sample_ratechannels等参数;实时结果写入 canonical 的live_capture转录行,并伴随transcript_live_state/transcript_live_deltas序列化增量(src/data/live-transcription.ts),保证断点续传与桌面端增量语义一致。
  • 批处理转写:当使用导入的音频、批处理模型,或实时链路失败时,走批处理通道。实现见 src/data/transcribe.ts:将音频 POST 到${env.apiUrl}/stt/listenprovider=anarlog),成功后将结果写入source='batch_transcription'的 transcript 行(整会话替换语义),并附带可选的说话人索引提示(provider_speaker_index);若提供商只返回纯文本而无词级时间戳,则按每词 400ms 生成合成时间轴(SYNTHETIC_TEXT_WORD_MS,与桌面batch.tssynthetic_text回退行为一致,见 src/data/transcribe.ts)。
  • 完成标记:两条路径成功后都会把对应session_attachmentsmetadata_json.transcript_status置为completeMARK_COMPLETE_SQL),供查询层区分"已转写"与"待转写"。

值得注意的工程细节:

  • 请求超时按文件大小动态预算(基础 60 秒,每 KiB 叠加上传+处理各 8ms,上限 900 秒),避免长录音被误杀(src/data/transcribe.ts);
  • 响应做了严格的边界防护(MAX_TRANSCRIPTION_WORDSMAX_TRANSCRIPTION_RESPONSE_BYTES),空结果绝不会标记完成,保留"点击重试"入口(src/data/transcribe.ts);
  • 并发通过TranscriptionAdmission(2, 32)限流(同时最多 2 个在跑、队列上限 32),并对永久性失败(如音频缺失、过大)加入自动重试黑名单,避免反复请求。

6.3 BYOK:原生适配器与 OpenAI 兼容端点

自带 Key(BYOK)的差异化处理在 AGENTS.md 中有明确分工:

  • 原生 BYOK 转写:复用owhisper-client适配器,经mobile-bridge调用(Rust 侧适配器在 crates/owhisper-client);
  • Custom 转写:走 OpenAI 兼容的 HTTP 端点(requestProviderTranscription,见 src/data/provider-transcription.ts)。

同时,转写前会通过resolveProvider("stt")batchTranscriptionModel判断所选提供商是否支持批处理;若该提供商只支持实时(live-only),保存的录音将保留可用状态,提示用户更换提供商后重试,而不是丢失录音(对应 AGENTS.md 中 "Live-only providers keep failed recordings available for retry" 的规则,代码中的stt_live_only错误码见 src/data/transcribe.ts)。

七、云同步与 E2EE 恢复

同步能力同样由 Rust 桥提供。src/db/client.ts暴露了一组与cloudsync相关的接口:

  • startSync/stopSync/syncNow/getSyncStatus分别对应桥层的startCloudsync/stopCloudsync/cloudsyncSyncNow/cloudsyncStatus(src/db/client.ts),MobileSyncStatus结构体包含configuredrunninghas_unsent_changeslast_sync_at_mslast_errorconsecutive_failures等字段,供同步状态页展示;
  • E2EE 恢复密钥generateE2eeRecoveryKey/inspectE2eeRecoveryKey生成并校验恢复密钥(格式校验为 43 位 base64url 的公钥与 22 位 keyId);
  • 设备注册generateE2eeDeviceEnrollmentKey/inspectE2eeDeviceEnrollmentKey/openE2eeDeviceEnrollment完成新设备加入工作区(Workspace)的密钥协商;
  • 副本引导bootstrapE2eeReplica是完整流程——先用恢复密钥校验身份,向 API 请求副本凭据(requestReplicaCredentials,见 src/sync/replica-credentials.ts),再以witnessEndpoint = /sync/e2ee/witness/<workspaceId>配置 E2EE 副本,最后自动startSync()开始同步(src/db/client.ts)。

同步的运行时生命周期(AppState 驱动的触发、设备注册、身份管理等)集中在 src/sync/ 目录,例如controller.ts(同步控制器)、mobile-sync-lifecycle.tsx(生命周期接入)、opt-in.ts(用户选择加入)等,并有配套测试(controller.test.mjsdevice-enrollment.test.mjsreplica-credentials.test.mjs等)验证状态机行为。

八、工程规则与约束

AGENTS.md 的 Rules 一节是移动端开发必须遵守的四条纪律,值得开发者重点内化:

  1. 本地写入永不等待网络:所有本地写操作立即返回,远程副作用(上传、同步、转写回调)随后以 best-effort 方式执行——这是"本地优先"体验不被弱网拖垮的根本保证;
  2. Schema/SQL 与桌面严格对齐:不发明移动端专属列或枚举。canonical schema 与迁移在crates/db-app,会话 SQL 以 apps/desktop/src/session/queries.ts 为基准;
  3. 营销版本独立管理:移动端版本存于 apps/mobile/release-version.json,用node scripts/release-version.mjs --mobile <major.minor.patch>更新,勿与桌面版本混淆;
  4. UX 以设计文档为准:界面交互的参考规范位于 apps/mobile/design/README.md。

九、小结

Anarlog Mobile 展示了一种"本地优先 + Rust 核心 + 云端增强"的移动端工程范式:通过 UniFFI 把 SQLite、E2EE 与同步逻辑沉淀在 Rust 层(crates/mobile-bridge 与 crates/db-app),JS 侧仅负责契约化调用与 UI;转写采用"实时流式 + 批处理回退"双通道,既保证即时反馈,又不丢失任何录音;计费门禁则通过移植自桌面的 JWT-claims 逻辑在 Hermes 上保持一致。如果你正在设计跨端本地优先应用,本文涉及的"响应式 SQL 契约层、SQL 语义跨端对齐、Hermes 环境下的依赖移植、失败可重试的转写状态机"都是可以直接借鉴的落地经验。

【免费下载链接】anarlogOpen source Granola AI Alternative项目地址: https://gitcode.com/GitHub_Trending/hy/anarlog

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询