Flutter 混合开发基座,将 H5 页面嵌入 WebView,并通过 JSBridge 暴露原生设备能力给 H5 调用。支持 Android 和 iOS 平台。
┌─────────────────────────────────────────────┐
│ H5 页面 (WebView) │
│ window.$bridge.module.method(params) │
├─────────────────────────────────────────────┤
│ bridge_sdk.js (自动注入) │
│ Promise 风格 API + 模块化封装 │
├─────────────────────────────────────────────┤
│ flutter_inappwebview (通信通道) │
├─────────────────────────────────────────────┤
│ BridgeRegistry → BridgeModule (16个模块) │
├─────────────────────────────────────────────┤
│ Flutter 原生插件 (image_picker, geolocator…)│
└─────────────────────────────────────────────┘
核心流程: H5 调用 → JS SDK 序列化 → WebView 通道 → BridgeRegistry 分发 → BridgeModule 执行 → 返回 BridgeResult
- Flutter SDK (Dart ^3.11.5)
- Android Studio / Xcode
- Android: Java 17
Windows:
setx PUB_HOSTED_URL https://pub.flutter-io.cn
setx FLUTTER_STORAGE_BASE_URL https://storage.flutter-io.cnmacOS/Linux (添加到 ~/.bashrc 或 ~/.zshrc):
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cnGradle 镜像已在 android/build.gradle.kts 中配置阿里云 Maven 源。
核心配置集中在 lib/config/app_config.dart,通过 kReleaseMode 自动区分 Debug / Release 环境:
class AppConfig {
// API 基础地址(登录、授权码、用户信息等接口)
static const String apiBaseUrl = kReleaseMode
? 'https://api.example.com' // Release
: 'https://m1.apifoxmock.com/m1/8319886-8083682-default'; // Debug (Mock)
// 底部 5 个 Tab 对应的 H5 页面地址,顺序为:首页、任务、一张图、通讯录、我的
static const List<String> tabUrls = kReleaseMode
? [
'https://www.example.com/home',
'https://www.example.com/task',
'https://www.example.com/map',
'https://www.example.com/contacts',
'https://www.example.com/mine',
]
: [
'https://oss.liliudong.me/test.html',
'https://oss.liliudong.me/test.html',
'https://oss.liliudong.me/test.html',
'https://oss.liliudong.me/test.html',
'https://oss.liliudong.me/test.html',
];
}| 配置项 | 说明 |
|---|---|
apiBaseUrl |
后端 API 基础地址,用于 /auth/login、/auth/code、/auth/me、/auth/logout |
tabUrls |
5 个 Tab 页面的 H5 地址,顺序对应:首页、任务、一张图、通讯录、我的 |
Debug 模式下可通过设置页面(首页右上角齿轮图标)临时修改 Tab 地址,修改值存储在本地 SharedPreferences 中,优先级高于 AppConfig.tabUrls 默认值,支持一键恢复默认。
# 安装依赖
flutter pub get
# 查看可用设备
flutter devices
# 运行
flutter run
# 构建 Android APK
flutter build apk
# 构建 iOS
flutter build ios# 运行全部测试
flutter test
# 运行单个测试文件
flutter test test/bridge/bridge_registry_test.dart
# 带覆盖率
flutter test --coverage
# 静态分析
flutter analyze
# 代码格式化
dart format .SDK 自动注入到 WebView 中,H5 页面无需手动引入任何脚本。
if (window.$bridge) {
console.log('JSBridge SDK 已就绪');
}// 直接通过模块名+方法名调用,参数更直观
const result = await window.$bridge.image.pick();
const result = await window.$bridge.storage.get('user_token');
const result = await window.$bridge.map.open(39.9, 116.4, '天安门');const result = await window.$bridge.invoke('image.pick');
const result = await window.$bridge.invoke('storage.get', { key: 'user_token' });两种方式完全等价,推荐使用方式一,代码更简洁且 IDE 自动补全更友好。
| 模块 | 方法 | 说明 |
|---|---|---|
permission |
check(type), request(type) |
权限管理 |
camera |
takePhoto() |
拍照 |
image |
pick() |
从相册选择图片 |
location |
getCurrent() |
获取当前位置 |
device |
getInfo() |
获取设备信息 |
clipboard |
copy(text), read() |
剪贴板读写 |
storage |
get(key), set(key, value), remove(key), clear(), getAll() |
持久化存储 |
vibrate |
vibrate(type?) |
震动/触觉反馈 |
phone |
call(number), sendSms(number, body?) |
拨打电话、发送短信 |
share |
shareText(text, subject?), shareImage(path, text?) |
系统分享 |
network |
getStatus() |
网络状态检测 |
map |
open(lat, lng, title?), navigate(lat, lng) |
打开地图、导航 |
file |
pick(multi?, types?), save(path, name?), open(path) |
文件操作 |
media |
saveToAlbum(path, type?), preview(paths) |
媒体操作 |
navigation |
setTitle(title), close(), open(url, title?), reload(), goBack(), goForward() |
WebView 导航控制 |
auth |
getAuthCode(appId) |
OAuth2 授权码申请 |
所有 API 返回统一的 Promise:
interface BridgeResult {
code: number; // 0 = 成功,非0 = 错误
data?: any; // 响应数据
message: string; // 状态消息
}H5 应用集成壳子登录态的标准流程,遵循 OAuth2 授权码模式。
流程说明:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ H5 应用 │ │ 壳子应用 │ │ 后端服务 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 自检登录状态 │ │
│ (未登录) │ │
│ auth.getAuthCode │ │
│ { appId: 'xxx' } │ │
│ ──────────────────>│ │
│ │ GET /auth/code │
│ │ ?appId=xxx │
│ │ ──────────────────>│
│ │ 返回授权码 code │
│ │ <──────────────────│
│ 返回 { code } │ │
│ <──────────────────│ │
│ │ │
│ 携带 code 请求自己的后端验证登录 │
│ ──────────────────────────────────────>│
│ 返回 H5 应用的登录态 │
│ <──────────────────────────────────────│
H5 端接入代码:
// 1. H5 应用自检登录状态(如检查 cookie / localStorage)
function isLoggedIn() {
return !!localStorage.getItem('h5_token');
}
// 2. 未登录时向壳子申请授权码
async function loginWithShell() {
try {
const result = await window.$bridge.invoke('auth.getAuthCode', {
appId: 'your-app-id' // H5 应用在壳子端注册的应用标识
});
if (result.code === 0) {
const authCode = result.data.code;
// 3. 将授权码发送到 H5 自己的后端,后端通过授权码换取用户信息
const loginResult = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: authCode })
});
const data = await loginResult.json();
if (data.success) {
localStorage.setItem('h5_token', data.token);
console.log('登录成功');
}
}
} catch (error) {
console.error('授权登录失败:', error);
}
}
// 4. 页面加载时自动检查
if (!isLoggedIn()) {
loginWithShell();
}错误码:
| 错误码 | 含义 |
|---|---|
| 9001 | 用户未登录(壳子登录态失效) |
| 9002 | 获取授权码失败 |
注意事项:
appId为必填参数,需提前在壳子端注册- 授权码为一次性使用,用完即失效
- 如果壳子用户未登录(9001),H5 应引导用户返回壳子重新登录
// 拍照
const photo = await window.$bridge.camera.takePhoto();
if (photo.code === 0) {
console.log('照片路径:', photo.data.path);
}
// 权限检查 + 定位
const perm = await window.$bridge.permission.check('location');
if (perm.data.granted) {
const loc = await window.$bridge.location.getCurrent();
console.log(`位置: ${loc.data.lat}, ${loc.data.lng}`);
}
// 存储
await window.$bridge.storage.set('token', 'abc123');
const result = await window.$bridge.storage.get('token');
console.log(result.data.value); // "abc123"
// 复制到剪贴板
await window.$bridge.clipboard.copy('Hello World');
// 拨打电话
await window.$bridge.phone.call('10086');
// 震动反馈
await window.$bridge.vibrate.vibrate('light'); // light/medium/heavy
// 设置页面标题
await window.$bridge.navigation.setTitle('新标题');
// 在新容器中打开页面
await window.$bridge.navigation.open('https://example.com/detail', '详情页');
// 分享
await window.$bridge.share.shareText('分享内容');
// 选择文件
const files = await window.$bridge.file.pick(false, ['image']);接收原生推送的事件:
window.addEventListener('bridge:web.error', (event) => {
console.error('WebView 错误:', event.detail.message);
});try {
const result = await window.$bridge.camera.takePhoto();
if (result.code !== 0) {
switch (result.code) {
case 1001: console.log('用户取消'); break;
case 1002: console.log('权限被拒绝'); break;
case 1003: console.log('参数无效'); break;
default: console.log('错误:', result.message);
}
return;
}
// 处理成功结果
} catch (error) {
console.error('调用异常:', error);
}错误码速查:
| 错误码 | 含义 | 错误码 | 含义 |
|---|---|---|---|
| 0 | 成功 | 3001 | 位置服务未开启 |
| 1001 | 用户取消 | 3002 | 位置获取超时 |
| 1002 | 权限被拒绝 | 4001 | 存储操作失败 |
| 1003 | 参数无效 | 5001 | 网络不可用 |
| 1004 | 通信错误 | 6001 | 文件不存在 |
| 2001 | 相机不可用 | 8001 | 不支持的操作 |
| 9001 | 用户未登录 | 9002 | 获取授权码失败 |
添加新的原生能力只需两步:
在 lib/bridge/modules/ 下新建文件,实现 BridgeModule 接口:
// lib/bridge/modules/my_module.dart
import '../bridge_module.dart';
import '../bridge_result.dart';
class MyModule implements BridgeModule {
@override
String get name => 'myModule'; // JS 端通过 window.$bridge.myModule.xxx() 调用
@override
Map<String, BridgeHandler> get handlers => {
'doSomething': _doSomething,
};
Future<BridgeResult> _doSomething(Map<String, dynamic> args) async {
final param = args['param'] as String?;
if (param == null) {
return BridgeResult.error(BridgeErrorCodes.invalidParams, 'param is required');
}
// 调用原生能力...
return BridgeResult.success(data: {'result': 'ok'});
}
}在 lib/main.dart 中添加注册:
final registry = BridgeRegistry()
// ...已有模块
..register(MyModule());在 assets/js/bridge_sdk.js 中添加对应的模块封装:
bridge.myModule = {
doSomething: function (param) {
return bridge.invoke('myModule.doSomething', { param: param });
}
};完成后 H5 即可通过 window.$bridge.myModule.doSomething('xxx') 调用。
Debug 工具仅在非 Release 模式下启用(DevConfig.debugToolsEnabled),Release 包中不会包含。
所有 H5 页面在 Debug 模式下自动注入 Eruda 移动端调试工具,提供类似 Chrome DevTools 的调试体验。
注入原理:
assets/js/eruda.js(约 500KB)随 APK/IPA 打包,通过rootBundle加载- 每次 WebView 页面加载完成(
onLoadStop)时,先注入 Eruda JS,再调用eruda.init() - 无需网络,完全离线可用
使用方式:
- 以 Debug 模式运行应用(
flutter run) - 打开任意 H5 页面
- 页面右下角出现 Eruda 齿轮图标
- 点击齿轮展开调试面板
功能面板:
| 面板 | 功能 |
|---|---|
| Console | 查看所有 console.log/warn/error 输出 |
| Network | 查看 XHR/Fetch 网络请求及响应 |
| Elements | 检查 DOM 结构和样式 |
| Resources | 查看 Cookie、LocalStorage、SessionStorage |
| Sources | 查看页面源码和 JS 文件 |
| Info | 设备信息和页面性能数据 |
适用场景:
- H5 页面白屏排查:通过 Console 查看报错
- 接口调试:通过 Network 查看 XHR 请求和响应
- 样式调试:通过 Elements 检查 DOM 和 CSS
- 存储调试:通过 Resources 查看 LocalStorage 数据
首页顶部导航栏齿轮图标,可输入任意 URL 打开 WebView,并附带 Debug Panel 底部抽屉,支持按类型筛选日志(Console/Network/Bridge/Error)、一键复制导出。
Release 构建自动关闭。如需在 Debug 模式下关闭,修改 lib/debug/dev_config.dart:
static bool get debugToolsEnabled => false;lib/
├── main.dart # 入口:创建 Registry + Repository
├── app.dart # MaterialApp 配置
├── bridge/
│ ├── bridge_module.dart # BridgeModule 抽象类
│ ├── bridge_result.dart # BridgeResult + 错误码
│ ├── bridge_registry.dart # 模块注册与分发
│ └── modules/ # 15 个功能模块
├── webview/
│ ├── webview_page.dart # WebView 容器 + Bridge 接线
│ └── tab_webview.dart # Tab 内嵌 WebView
├── pages/
│ ├── main_page.dart # 主页面(底部导航 + 5 个 Tab)
│ └── login_page.dart # 登录页面
├── models/
│ └── app.dart # App 数据模型
├── repositories/
│ ├── app_repository.dart # 抽象接口
│ └── hardcoded_app_repository.dart # 硬编码实现(可替换为 API)
├── services/
│ └── permission_service.dart
└── debug/ # 调试工具(仅 Debug 模式)
assets/
├── js/bridge_sdk.js # H5 端 JSBridge SDK
└── test.html # API 测试页面
- H5 集成开发文档 - 完整的 API 参考手册