cubegao

Flutter 插件化架构与动态扩展能力

2024-08-26

一、被发版卡住的需求

2024 年,我们遇到了一个让业务方和客户端团队都很痛苦的场景:工作台中的轻应用(OA 审批、报表、公告等)有频繁的变更需求,但每次变更都必须跟客户端版本一起发布。

App 的发版周期是 2-4 周,iOS 还需要审核。业务方催:「审批流程加一个字段,为什么需要等三周?」客户端也很无奈:「改的是 H5 页面、不是原生代码,但它在 App 的 WebView 容器里,容器改了也得跟版本。」

这个矛盾催生了插件化架构的需求:让业务模块可以独立于 App 版本进行更新。本文复盘我们从零搭建插件化架构的过程,包括插件协议设计、动态加载机制、版本兼容策略和在鸿蒙平台上的适配经验。

二、插件化的核心问题

在动手之前,我们先定义了三个核心问题:

  1. 隔离性:一个插件的崩溃不能影响宿主 App 和其他插件的稳定性。
  2. 动态加载:插件代码和资源如何下载、加载、替换,而不需要重新编译 App。
  3. 版本兼容:宿主 App 和插件分别独立迭代,如何保证接口的向前兼容。

对于 Flutter 项目来说,「动态化」还有一层额外的挑战:Dart 代码在 Release 模式下是 AOT 编译的,没有字节码虚拟机。这意味着我们无法像 React Native 的 CodePush 那样直接下发 JavaScript 代码。只能在 Flutter 已有的机制上寻找方案。

三、整体架构设计

3.1 三层架构

1
2
3
4
5
6
7
8
9
┌────────────────────────────────────────┐
│ 宿主 App │
│ 插件管理器 / 路由分发 / 全局服务注册 │
├────────────────────────────────────────┤
│ 插件运行时框架 (Plugin SDK) │
│ 协议定义 / 生命周期管理 / 沙箱隔离 │
├──────────┬──────────┬─────────────────┤
│ OA 审批 │ 报表 │ 公告 │ ← 业务插件
└──────────┴──────────┴─────────────────┘

3.2 插件协议(Plugin Protocol)

插件与宿主 App 之间的通信通过标准协议完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 插件必须实现的接口
abstract class MPlugin {
/// 插件唯一标识
String get pluginId;

/// 插件名称
String get pluginName;

/// 插件版本
String get pluginVersion;

/// 插件入口 Widget
Widget buildEntry(BuildContext context, Map<String, dynamic> params);

/// 插件初始化(下载完成后的首次调用)
Future<void> onInit(MPluginContext context);

/// 插件销毁
Future<void> onDestroy();
}

// 插件上下文:提供宿主能力给插件
abstract class MPluginContext {
/// 发送网络请求(复用宿主的网络层)
Future<ApiResponse> request(String path, {Map<String, dynamic>? params});

/// 获取当前用户信息
Future<UserInfo> getCurrentUser();

/// 打开新的页面
Future<void> openPage(String route, {Map<String, dynamic>? params});

/// 注册事件监听
void onEvent(String event, void Function(dynamic data) callback);
}

这个设计的核心思路是:插件不直接访问网络、数据库、文件系统,所有系统能力通过 MPluginContext 代理。这保证了宿主 App 对插件的完全控制权——如果某个插件滥用网络请求,宿主可以在 Context 层面限流。

3.3 插件管理器的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MPluginManager {
final Map<String, MPlugin> _plugins = {};
final MPluginLoader _loader;

MPluginManager(this._loader);

// 注册内置插件(随 App 打包的插件)
void registerBuiltin(MPlugin plugin) {
_plugins[plugin.pluginId] = plugin;
}

// 动态加载远程插件
Future<void> loadRemote(String pluginId) async {
final plugin = await _loader.downloadAndLoad(pluginId);
if (plugin != null) {
// 如果已存在旧版本,先销毁
await _plugins[pluginId]?.onDestroy();
_plugins[pluginId] = plugin;
await plugin.onInit(_createContext(pluginId));
}
}

// 卸载插件
Future<void> unload(String pluginId) async {
final plugin = _plugins.remove(pluginId);
await plugin?.onDestroy();
}

Widget buildPlugin(String pluginId, {Map<String, dynamic>? params}) {
return _plugins[pluginId]?.buildEntry(context, params ?? {})
?? const ErrorWidget('Plugin not found');
}
}

四、动态加载的三种方式

在 Flutter 中实现动态化,业界主要有三种方案。我们逐一评估后做了组合使用。

4.1 方案一:Dart 代码下发 — CodePush(不适用)

原理:将 Dart 代码编译为 kernel 文件(.dill),运行时动态加载并执行。类似于 React Native 的 CodePush。

评估结论:不适用于生产环境。原因:

  • Flutter 官方明确表示不支持 Release 模式下的动态代码加载。AOT 模式下 Dart VM 不可用,无法执行 dart kernel 文件。
  • 即使强行在 Debug 模式下使用,也面临严重的安全风险——下发的代码可以执行任意 Dart 代码,等于开了一个后门。
  • iOS 的 App Store 审核明确禁止动态下载可执行代码。

在安全性和合规性要求极高的企业 IM 项目中,这条路走不通。

4.2 方案二:WebView 轻应用(部分适用)

原理:插件以 H5 页面的形式存在,通过 WebView 容器加载。页面更新只需刷新 WebView 缓存。

这是我们最核心的动态化方案,适用于工作台中的 OA 审批、报表、公告等非实时交互型业务。

在企业 IM 中的实践:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class WebViewPlugin extends MPlugin {
final String _baseUrl;

@override
Widget buildEntry(BuildContext context, Map<String, dynamic> params) {
return InAppWebView(
initialUrlRequest: URLRequest(url: Uri.parse('$_baseUrl?${Uri(queryParameters: params)}')),
onWebViewCreated: (controller) {
// 注入 JSBridge,提供原生能力
controller.addJavaScriptHandler(
handlerName: 'nativeApi',
callback: (args) => _handleNativeCall(args),
);
},
);
}
}

WebView 方案的优势:

  • 真正的动态更新:H5 页面部署到 CDN,客户端无需发版。
  • Web 团队直接参与:前端开发者不需要学 Flutter 就能开发业务插件。
  • 成熟生态:Vue/React/Angular 都可以作为 H5 开发框架。

WebView 方案的局限:

  • 性能上限:WebView 的渲染性能无法与原生自建 UI 相比。对于聊天页面、消息列表这种高交互性场景不适用。
  • 离线能力弱:没有网络时,缓存策略需要精心设计。
  • 原生能力受限:虽然可以通过 JSBridge 调用原生能力,但每次调用都是异步的,复杂交互的体验不如原生。

4.3 方案三:原生混合方案(渐进式)

第三类方案是「不追求纯 Flutter 动态化,而是利用平台原生能力」。在企业 IM 中,我们将部分高交互性但需要动态更新的场景(如自定义表情面板)用原生实现,通过 Platform Channel 嵌入 Flutter 页面。

在鸿蒙平台上,这个方案发挥了意想不到的作用:鸿蒙的系统 WebView 组件与 Android/iOS 有差异,某些 H5 插件在鸿蒙 WebView 中渲染异常。我们改为用鸿蒙原生的 ArkUI 实现这些页面,通过 Flutter Platform Channel 通信,反而获得了更好的体验。

五、版本兼容策略

插件和宿主 App 独立演进,版本兼容是最头疼的问题。

5.1 接口版本化

MPluginContext 中的每个方法都带版本号:

1
2
3
4
5
6
7
8
9
10
11
abstract class MPluginContext {
@ApiVersion('1.0')
Future<ApiResponse> request(String path, {Map<String, dynamic>? params});

@ApiVersion('2.0')
Future<ApiResponse> requestV2(String path, {RequestMethod method, Map<String, dynamic>? body});

// 推荐使用 v2,deprecated 不会删除,保证兼容
@Deprecated('Use requestV2 instead')
Future<ApiResponse> request(String path, {Map<String, dynamic>? params});
}

核心原则:只增加新接口,不修改已有接口的语义,不删除已发布的接口。旧接口标记 deprecated 后至少保留两个宿主大版本。

5.2 最低版本协商

插件可以在配置中声明对宿主 App 的最低版本要求:

1
2
3
4
5
6
{
"pluginId": "oa_approval",
"pluginVersion": "1.2.3",
"minHostVersion": "3.5.0",
"downloadUrl": "https://cdn.company.com/plugins/oa_approval_1.2.3.zip"
}

插件管理器在加载前检查宿主版本,不满足要求时提示用户升级 App。这个机制避免了「插件用了新 API 但在旧宿主上崩溃」的问题。

六、鸿蒙平台的适配经验

在将插件化架构扩展到鸿蒙平台时,遇到了两个特有的问题:

问题一:鸿蒙的 WebView 组件不支持某些 JSBridge 的注入机制。解决方案是为鸿蒙单独实现了一套基于原生 ArkUI Channel 的通信桥,在 WebView 插件加载时检测平台、注入对应的 JSBridge 代码。

问题二:鸿蒙的文件系统路径规则与 Android 不同。插件包下载后的解压路径需要做平台适配。解决方案是在 Plugin SDK 中抽象 MStorage 接口,各平台提供自己的实现。

七、总结

插件化架构在企业 IM 项目中的核心价值不是「技术炫酷」,而是解耦发布节奏。让审批、报表、公告这种高频变化的轻应用可以按天迭代,而不受客户端两周发版周期的约束。

三个关键设计决策:

  1. WebView 作为主要动态化手段。不是最优美的方案,但是最可靠、最合规的方案。
  2. MPluginContext 作为能力代理。插件不能直接访问任何系统能力,全部通过宿主可控的 Context 代理。这是安全性和稳定性的底线。
  3. 接口只增不改。版本兼容的黄金法则:新增容易,修改和删除需要巨大的代价。在插件架构设计的初期,宁可接口方法多一点,也不要后续破坏兼容性。

插件化不是终点。随着我们对模块化和微前端的探索(后续文章会展开),插件的边界会从「独立业务模块」逐步扩展到「可组合的业务单元」。

扫描二维码,分享此文章