先把 Web 产品装进手机里
ReAkh 的 Web 侧已经有完整产品逻辑。做移动端时,我不太想立刻维护一套全新的页面和状态,而是希望用尽量低的成本,把它做成一个足够稳定、方便日常使用的 App。
最终选了原生壳 + WebView
核心思路
整个 App 本质上是一个原生壳 + 内嵌 WebView:Web 前端承载全部页面逻辑,壳层只负责:
- 提供 WebView 容器;
- 封装 Web 无法直接调用的原生 API,例如语音识别、保存图片到相册;
- 处理应用生命周期,例如网络异常恢复、权限申请。
这样既能保留 Web 侧的用户体验,又能以最小工程量完成 App 上架。只维护一套业务代码,同时可通过 JS Bridge 按需扩展原生能力。
为什么选择 Flutter
| 方案 | 结论 |
|---|---|
| Flutter | 一套 Dart 代码可直接编译出 iOS 和 Android 包,后续桌面端也可复用;通过 addJavaScriptChannel 与 runJavaScript 即可完成双向通信;语音识别、图片保存、权限、网络状态都有成熟插件,且插件内部已抹平双端差异。 |
| React Native | 调用系统 API 往往需要分别编写 Swift / Kotlin Native Module,Bridge 协议也需要自行维护,当前阶段成本更高。 |
| 原生双端开发 | Swift + Kotlin 两套工程的维护成本翻倍,不适合快速验证阶段。 |
这版 App 的结构很简单
Flutter 壳以 WebView 承载 reakh.com / reakh.ai,通过 JS Bridge 补齐 Web 能力边界;语音、相册、外链和网络监听等能力由原生插件提供。

几个实现里最值得记录的细节
1. WebView 基础配置
几个关键决策:
JavaScriptMode.unrestricted:业务页面依赖 JavaScript,必须开启。NavigationDelegate:监听加载结果,配合网络恢复逻辑判断是否需要重启。- SafeArea 只保留顶部:设置
bottom: false,让 WebView 内容延伸到底部安全区;底部间距由 Web 侧处理,从而避免 iOS 底部出现空白条。
2. JS Bridge 设计
Bridge 共包含 4 个通道,覆盖 H5 → Native 和 Native → H5 两个方向:
| 通道名 | 方向 | 用途 |
|---|---|---|
AppJSBridge |
H5 → Native | 保存图片、打开外链等通用能力。 |
VoiceBridge |
H5 → Native | 语音识别控制,例如 start / stop。 |
window.receiveVoiceText |
Native → H5 | 回传识别结果文本。 |
window.updateVoiceLevel |
Native → H5 | 实时音量回调,用于驱动波形动画。 |
H5 侧只发送受控指令;Flutter 负责调用原生能力,并通过 runJavaScript 将语音文本和音量状态回传页面。这样业务页面不需要感知 iOS 与 Android 的底层差异。
3. 保存图片与打开外链
3.1 保存图片至相册
H5 无法直接写入系统相册,因此通过 AppJSBridge 把图片数据传给 Native,再由 Native 调用 image_gallery_saver 完成保存。
3.2 打开外部链接
WebView 内点击第三方链接会默认在 WebView 中打开,体验较差且用户不易返回。页面通过 openExternalUrl 指令调用系统浏览器,将外链交给 url_launcher 打开。
4. 语音识别集成
语音识别基于 Flutter 插件 speech_to_text 实现。它封装了 iOS 的 SFSpeechRecognizer 和 Android 的 SpeechRecognizer,对外提供统一的 Dart API:通过 SpeechToText 创建识别器对象,调用 initialize() 完成权限检测和引擎初始化,再通过 listen() 开始录音识别。
4.1 初始化策略:提前触发权限弹窗
权限申请放在 initState(应用启动时),而不是第一次录音时触发。
iOS 系统权限弹窗会打断当前触摸序列。若用户首次长按录音按钮时才出现弹窗,点击「允许」后松手,WebView 已无法收到 touchend 事件,H5 会误以为用户仍在按住,导致录音状态卡住无法退出。
在启动阶段申请权限,能确保第一次使用录音时权限已经就绪,彻底规避这个问题。
4.2 实时音量回调
speech_to_text 会持续从系统麦克风采样,将原始音频交给 iOS SFSpeechRecognizer 或 Android SpeechRecognizer 识别,并大约每 100ms 回调一次当前音量级别(double,范围约为 -2 ~ 10)。Flutter 收到音量后立即通过 runJavaScript 注入 H5,驱动波形动画。
5. 网络恢复自动重启
WebView 在无网络时会显示错误页或白屏。网络恢复后若不主动处理,用户通常需要杀掉 App 后重新启动。解决办法是监听网络状态,并在恢复后重建应用根组件。
5.1 根组件支持重建
这里的「根组件」是 Flutter 壳最外层的 MaterialApp,它承载应用路由和全部页面(包括 WebViewPage)。更换它的 Key 后,Flutter 会将其视为全新的组件:整棵 Widget 树、WebViewPage 与 WebViewController 都会销毁后重新创建,进而重新加载 URL。
相比直接调用 controller.reload(),重建根组件更可靠:网络断开时 WebView 内部状态可能已损坏,单纯 Reload 有时不够干净。
1 | class _RestartWrapperState extends State<_RestartWrapper> { |
5.2 网络恢复触发重启
仅在「无网 → 有网」且页面曾加载失败时触发重建,避免网络状态轻微波动造成不必要的页面刷新:
1 | void _onConnectivityChanged(List<ConnectivityResult> result) { |
6. 移动端适配
当前已处理的移动端体验包括:
- 收缩的侧边菜单;
- 适配移动端的 Message 组件;
- 滑动交互;
- 震动反馈。
先从 iOS 开始验证
当前优先在 iOS 侧完成测试与最小可行性验证。得益于跨端框架,Android 侧同样可以打包运行,但部分 JS Bridge 能力仍在验证中。
打包时,我给自己留了一个保险
为了降低打包出错概率,项目使用交互式脚本 ./build.sh。每次打包前,脚本会强制确认两个关键参数:版本号和 WebView 加载 URL。
脚本执行流程:
- 选择平台(iOS / Android);
- 确认或修改版本号(自动从
pubspec.yaml读取当前版本); - 确认或修改 WebView 加载 URL(从
main.dart读取initialUrl); - 执行
flutter build ipa。
第 3 步尤其重要:它避免将指向内网开发地址的 WebView URL 打进生产包。这类失误早期出现过一次,因此加入了这个明确的发布前确认步骤。
上线计划
| 平台 | 发布计划 |
|---|---|
| iOS 国内 Store | 上线 reakh.com。 |
| iOS 海外 Store | 上线 reakh.ai。 |
| Android | 暂定。 |
还想继续补上的能力
- 上线更多能力,例如沙箱、多模态输出;
- 支持第三方登录:Apple、WeChat、Google 等;
- 梳理宣发切入点;
- 持续完成 Android 侧优化、测试与验证。