阅读视图

发现新文章,点击刷新页面。

Elpis 动态组件扩展设计:配置驱动的边界与突破

配置驱动的边界问题

Elpis 通过配置驱动解决了 80% 的中后台 CRUD 场景,但总会遇到内置组件无法覆盖的情况:

  • 需要省市区三级联动选择器
  • 需要带千分位格式化的金额输入框
  • 需要集成公司自研的图片裁剪上传组件
  • 需要富文本编辑器、图表组件等第三方库

这时候有三个选择:

方案 A:放弃配置驱动,回到手写代码

方案 B:等框架作者更新内置组件

方案 C:自己扩展组件,像内置组件一样使用

Elpis 选择了方案 C,通过动态组件扩展机制,让框架既保持标准化,又具备灵活性。

核心设计:一个"字符串"的魔法

Elpis 的扩展机制说穿了就一个核心思想:配置里写的是字符串,渲染时才决定用哪个组件

看这段配置:

product_name: {
  createFormOption: {
    comType: 'input',  // 这只是个字符串
  }
}

这个 'input' 不是直接对应某个组件,而是一个"代号"。真正的组件在哪?在一个叫"注册中心"的地方:

// form-item-config.js
const FormItemConfig = {
  'input': { component: InputComponent },
  'select': { component: SelectComponent },
  'richEditor': { component: RichEditorComponent }
};

渲染时,Elpis 做的事情很简单:

<component :is="FormItemConfig[配置里的comType].component" />

就这样,配置和组件解耦了。你想加新组件?往注册中心加一行,配置里就能用。

这个设计妙在哪?

1. 配置稳定: 即使你把 InputComponent 整个重写了,配置文件一个字都不用改。因为配置里只是写了个 'input' 字符串。

2. 场景隔离: 搜索栏有自己的注册中心,表单有自己的注册中心。同样是 'input',在搜索栏可能是个简单输入框,在表单里可能是个带校验的复杂组件。

3. 扩展简单: 不需要改框架代码,不需要发 PR,不需要等更新。自己加一行注册,立刻就能用。

实战:扩展一个富文本编辑器组件

通过实际案例演示如何扩展组件。假设需要添加富文本编辑器支持。

第一步:实现组件

创建文件 app/pages/widgets/schema-form/complex-view/rich-editor/rich-editor.vue

<template>
  <div class="form-item">
    <div class="item-label">
      <span>{{ schema.label }}</span>
      <span v-if="schema.option?.required" class="required">*</span>
    </div>
    <div class="item-value">
      <QuillEditor v-model:content="value" />
      <div v-if="!isValid" class="valid-tips">{{ validMessage }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';

const props = defineProps({
  schemaKey: String,    // 字段名
  schema: Object,       // 字段配置
  model: String         // 初始值
});

const value = ref(props.model || '');
const isValid = ref(true);
const validMessage = ref('');

// 必须实现的接口方法
const validate = () => {
  if (props.schema.option?.required && !value.value) {
    isValid.value = false;
    validMessage.value = '这个字段必填';
    return false;
  }
  isValid.value = true;
  return true;
};

const getValue = () => {
  return { [props.schemaKey]: value.value };
};

defineExpose({ validate, getValue });
</script>

关键约定

  • Props 必须包含 schemaKeyschemamodel
  • 必须暴露 validate()getValue() 方法
  • 其他实现细节可自由发挥

第二步:注册组件

app/pages/widgets/schema-form/form-item-config.js 中注册:

import richEditor from "./complex-view/rich-editor/rich-editor.vue";

const FormItemConfig = {
  input: { component: input },
  select: { component: select },
  richEditor: { component: richEditor }  // 新增注册
};

第三步:配置使用

在业务模型中使用新组件:

product_description: {
  type: 'string',
  label: '商品描述',
  createFormOption: {
    comType: 'richEditor',  // 使用扩展组件
    required: true
  }
}

完成。刷新页面,富文本编辑器自动渲染,校验、提交等功能自动生效。

背后的技术:Vue 3 的动态组件

你可能好奇 Elpis 是怎么做到"运行时决定渲染哪个组件"的。答案是 Vue 3 的 <component :is>

看 Elpis 的核心渲染代码:

<template>
  <template v-for="(itemSchema, key) in schema.properties">
    <component
      :is="FormItemConfig[itemSchema.option?.comType]?.component"
      :schemaKey="key"
      :schema="itemSchema"
      :model="model[key]"
    />
  </template>
</template>

这段代码在做什么?

  1. 遍历配置里的每个字段
  2. 读取字段的 comType(比如 'input'
  3. 从注册中心找到对应的组件(FormItemConfig['input'].component
  4. :is 动态渲染这个组件

关键点:is 后面可以是一个变量,这个变量的值是什么组件,就渲染什么组件。

这就是为什么你改配置文件就能换组件——因为组件是运行时决定的,不是编译时写死的。

一个容易忽略的细节:统一接口

注意到没有,所有组件都接收同样的 props:

:schemaKey="key"
:schema="itemSchema"
:model="model[key]"

这是 Elpis 的"约定"。只要你的组件遵守这个约定,就能被动态渲染。

这就像 USB 接口,不管你是键盘、鼠标还是 U 盘,只要接口对得上,就能插上用。

所以写扩展组件时,记住三件事:

  1. Props 要有 schemaKeyschemamodel
  2. 要暴露 validate()getValue() 方法
  3. 其他的随便你发挥

对比:Elpis vs 其他方案

vs Element Plus / Ant Design(组件库)

组件库:给你一堆组件,你自己拼。

<el-form>
  <el-form-item label="商品名称">
    <el-input v-model="form.name" />
  </el-form-item>
  <el-form-item label="价格">
    <el-input-number v-model="form.price" />
  </el-form-item>
  <!-- 每个字段都要写 -->
</el-form>

Elpis:写个配置,自动生成。

{
  product_name: { createFormOption: { comType: 'input' } },
  price: { createFormOption: { comType: 'inputNumber' } }
}

结论:组件库灵活但重复劳动多,Elpis 标准化但省事。适用场景不同,不是替代关系。

vs Formily / React JSON Schema Form(表单方案)

JSON Schema 表单:只管表单,其他的你自己搞。

Elpis:搜索 + 表格 + 表单 + 详情,一套配置全搞定。

结论:Elpis 是 JSON Schema 思想在整个中后台系统的延伸。

写在最后

Elpis 的动态组件扩展机制核心就三件事:

  1. 配置里写字符串标识,不直接引用组件
  2. 用注册中心做类型映射,字符串对应具体组件
  3. 用 Vue 的 :is 实现运行时动态渲染

这套设计让框架在标准化和灵活性之间找到了平衡:

  • 80% 的场景用内置组件,配置驱动,快速开发
  • 20% 的场景扩展组件,一次封装,到处复用

扩展组件的成本是一次性的,但收益是长期的。当你的组件库逐渐丰富,配置驱动的威力就会越来越明显。

框架的价值不在于限制开发者,而在于提供清晰的扩展路径,让开发者在需要时能够突破标准化的边界。

引用: 抖音“哲玄前端”《大前端全栈实践》

拒绝重写!Flutter Add-to-App 全攻略:让原生应用“渐进式”拥抱跨平台

为什么我们需要 Add-to-App?

unwatermarked_Gemini_Generated_Image_51ku3f51ku3f51ku.png 在移动开发领域,Flutter 的跨平台优势(Write once, run anywhere)毋庸置疑。但在现实世界中,我们往往面临着沉重的“历史包袱”。

痛点场景:

“我们公司有一个维护了 5 年的电商 App,原生代码几十万行。最近老板嫌 UI 迭代慢,想用 Flutter,但完全重写是不可能的——业务线太长,风险太大。我们要的是渐进式的改变。”

这就是 Add-to-App 存在的意义。它允许我们将 Flutter 视为一个“库”或“模块”,嵌入到现有的 Android 或 iOS 应用中。

它的核心价值在于:

  1. 成本控制:无需抛弃现有的原生资产(支付模块、复杂的底层算法等)。
  2. 渐进迁移:可以从一个非核心页面(如“关于我们”或“活动页”)开始,逐步扩大 Flutter 的版图。
  3. 复用能力:新开发的 Flutter 模块可以直接在 Android 和 iOS 甚至 Web 上复用,从一开始就享受跨平台红利。

Add-to-App 的基本概念与原理

什么是 Add-to-App?

简单来说,Add-to-App 就是把 Flutter 环境(Dart VM + Flutter Engine)打包成一个原生组件(View 或 ViewController/Activity),塞进现有的原生 App 里。

  • 对于 Android:Flutter 只是一个 View,或者一个 Activity/Fragment。
  • 对于 iOS:Flutter 只是一个 UIView,或者 FlutterViewController。

运行模式:多引擎 vs 多视图

在混合开发中,理解 Flutter 的“寄生”方式至关重要:

策略 描述 优点 缺点
单引擎复用 (Single Engine) 全局维护一个 Engine,在不同原生页面间跳转时,通过 attach/detach 挂载到当前界面。 内存占用最低;状态不仅共享且保持。 导航栈管理极其复杂(原生页面 A -> Flutter B -> 原生 C -> 返回 B 时需恢复现场)。
多引擎 (Multi-Engine) 每次打开 Flutter 页面都创建一个新 Engine。 逻辑隔离,互不干扰;导航栈管理简单。 内存爆炸(每个 Engine 默认消耗较大),启动延迟明显。
FlutterEngineGroup (推荐) 官方提供的轻量级多引擎方案(Flutter 2.0+)。 多个 Engine 共享 GPU 上下文、字体和代码段,新增一个 Engine 仅需 ~180KB 内存 Dart Isolate 彼此隔离,状态不共享(需通过数据层同步)。

误区提示:桌面端/Web 支持的“多视图(Multi-view)”模式(即一个 Engine 渲染多个窗口)目前尚未在移动端 Add-to-App 场景中稳定支持。在移动端,请优先考虑 FlutterEngineGroup

最佳实践场景

  • 高频迭代的业务模块:如电商的活动页、个人中心。
  • 复杂的 UI 交互:如需要高性能动画的图表页。
  • 统一逻辑:双端逻辑完全一致的表单提交或业务计算。

实战 I:在 Android 原生 App 中嵌入 Flutter

创建 Flutter Module

注意,我们不能 flutter create my_app,因为我们不需要一个完整的 App 壳子,我们需要的是一个模块

# 在原生项目同级目录下执行
flutter create -t module my_flutter_module

执行后,你会发现生成的目录结构中,androidios 文件夹是隐藏的(.android, .ios),因为它们是自动生成的包装器。

将 Flutter Module 导入 Android 项目

自 Flutter 3.x 起,官方推荐通过 Gradle 脚本自动管理依赖,避免手动编写 implementation 导致的版本冲突。

步骤 1:修改 settings.gradle

在 include ':app' 之后加入:

// 绑定 Flutter 模块构建脚本
setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir.parentFile, // 假设 flutter_module 与当前项目同级
  'my_flutter_module/.android/include_flutter.groovy'
))

步骤 2:修改 app/build.gradle

依赖会自动注入,通常无需手动添加 implementation project(':flutter')。但需确保 compileSdkVersion 与 Flutter 模块要求一致(通常需 API 33+)。

在 Android 上渲染 Flutter (Activity 与 Fragment)

方式 A:使用 FlutterActivity(全屏场景)

适合独立的业务流程,如“个人中心”或“设置页”。

// 使用缓存 Engine 启动(推荐)
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

方式 B:使用 FlutterFragment(局部嵌入)

适合将 Flutter 作为一个 View 块嵌入原生页面,例如在一个原生 Tab 页中展示 Flutter 列表。

// 在原生 Activity 或 Fragment 中
FragmentManager fragmentManager = getSupportFragmentManager();
fragmentManager
    .beginTransaction()
    .replace(R.id.fragment_container, 
             FlutterFragment.withCachedEngine("my_engine_id").build())
    .commit();

性能优化 Tip:使用缓存 Engine

withNewEngine() 会导致每次打开页面都有明显的“白屏”或加载延迟。推荐使用 FlutterEngineCache 进行预热:

// 1. 在 Application 启动时预热
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // 实例化 Engine
        FlutterEngine flutterEngine = new FlutterEngine(this);
        // 开始执行 Dart 代码(预加载)
        flutterEngine.getDartExecutor().executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        );
        // 存入缓存
        FlutterEngineCache
            .getInstance()
            .put("my_engine_id", flutterEngine);
    }
}

// 2. 启动时使用缓存的 Engine
startActivity(
    FlutterActivity
        .withCachedEngine("my_engine_id")
        .build(this)
);

实战 II:在 iOS 原生 App 集成 Flutter

创建 Flutter Module

(同上,使用同一个 my_flutter_module 即可)

CocoaPods 集成

这是 iOS 最标准的集成方式。

修改 Podfile

在 iOS 工程的 Podfile 中添加脚本钩子:

# Podfile
platform :ios, '14.0'

# 定义 Flutter 模块路径
flutter_application_path = '../my_flutter_module'

# 加载 Flutter 的 Pod 助手脚本
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

target 'MyApp' do
  use_frameworks!
  
  # 安装 Flutter 依赖
  install_all_flutter_pods(flutter_application_path)
end

执行 pod install,你会发现 Flutter 相关的 Framework 已经被链接进来了。

在 iOS 中打开 Flutter View

使用 FlutterViewController

import Flutter

// 在某个按钮点击事件中
@objc func showFlutter() {
    // 获取 Flutter Engine(同样建议使用 Cache,这里演示简单模式)
    let flutterEngine = (UIApplication.shared.delegate as! AppDelegate).flutterEngine
    
    let flutterViewController = FlutterViewController(
        engine: flutterEngine, 
        nibName: nil, 
        bundle: nil
    )
    
    present(flutterViewController, animated: true, completion: nil)
}

在 iOS 中使用缓存 Engine

为了避免点击按钮时卡顿,强烈建议在 App 启动时预热 Engine。

步骤 1:在 AppDelegate 中初始化并缓存

import UIKit
import Flutter
import FlutterPluginRegistrant // 用于注册插件

@main
class AppDelegate: FlutterAppDelegate { // 继承 FlutterAppDelegate
  
  lazy var flutterEngine = FlutterEngine(name: "my_engine_id")

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    // 1. 运行 Engine (预热)
    flutterEngine.run();
    // 2. 注册插件(关键!否则 Flutter 里的插件无法使用)
    GeneratedPluginRegistrant.register(with: flutterEngine);
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions);
  }
}

步骤 2:使用缓存 Engine 弹出页面

@objc func showFlutter() {
    let appDelegate = UIApplication.shared.delegate as! AppDelegate
    let flutterEngine = appDelegate.flutterEngine
    
    let flutterVC = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil)
    present(flutterVC, animated: true, completion: nil)
}

进阶:原生与 Flutter 的双向通信 (MethodChannel)

当混合开发时,不可避免地需要数据交互:Flutter 读取原生的 Token,或者原生调用 Flutter 的刷新方法。MethodChannel 是最常用的桥梁。

5.1 Flutter 端 (Dart)

import 'package:flutter/services.dart';

class NativeBridge {
  static const platform = MethodChannel('com.example.app/data');

  // 调用原生方法
  Future<String> getUserToken() async {
    try {
      final String token = await platform.invokeMethod('getToken');
      return token;
    } on PlatformException catch (e) {
      return "Failed: '${e.message}'.";
    }
  }
}

5.2 Android 端

// 需在 Engine 初始化后注册 Channel
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), "com.example.app/data")
    .setMethodCallHandler(
        (call, result) -> {
            if (call.method.equals("getToken")) {
                // 执行原生逻辑获取 Token
                String token = MyAuthManager.getToken();
                result.success(token);
            } else {
                result.notImplemented();
            }
        }
    );

注意事项:

MethodChannel 并非能传递任意对象,它的底层依赖 BinaryMessenger 进行二进制流传输。

  • StandardMethodCodec(标准编解码器) : Flutter 默认使用此 Codec,它只支持高效序列化以下基础类型

    • null, bool, int, double, String
    • List, Map (仅限上述基础类型的集合)
    • 二进制数据 (Uint8List / byte[])

注意:如果你尝试直接传递一个自定义类 User,通道会报错。 解决方案:将对象转为 JSON String 或 Map 进行传递,或者自定义 Codec。


进阶:混合栈管理与多 Engine 挑战

在 Add-to-App 中,最头疼的问题往往是 导航栈(Navigation Stack)。

比如:原生 A -> Flutter B -> 原生 C -> Flutter D

挑战

  1. 内存爆炸:如果每次 > Flutter 都创建一个新 Engine,内存会迅速耗尽。
  2. 状态丢失:如果复用同一个 Engine,从 C 返回 B 时,Flutter 的状态怎么恢复?

解决方案策略

当原生应用需要在 Feed 流中嵌入多个 Flutter 卡片,或者同时存在多个 Flutter 页面栈时,单纯的“单引擎”或“多引擎”都不够完美。

终极方案:FlutterEngineGroup 这是官方为了解决“多实例内存占用”推出的 API。

原理: 它允许你创建多个 Engine 实例,这些实例共享内存重的资源(如 Skia Shader、字体、Dart VM 快照),但保持 Dart Isolate 隔离

代码示例 (Android)

// 创建 EngineGroup
FlutterEngineGroup engineGroup = new FlutterEngineGroup(context);

// 创建第一个轻量级 Engine
FlutterEngine engine1 = engineGroup.createAndRunDefaultEngine(context);

// 创建第二个轻量级 Engine(复用资源,内存开销极低)
FlutterEngine engine2 = engineGroup.createAndRunDefaultEngine(context);

状态管理挑战: 由于 EngineGroup 中的 Isolate 是隔离的,engine1 中的全局变量无法被 engine2 直接读取。

  • 解决:相比于通过原生层(Host)作为中转站,或者使用持久化存储(Database/SharedPrefs)来同步不同 Flutter 页面间的数据。使用平台通道并且搭配上pigeon,相信会给你复杂原生交互提供不少的便利。

7. 常见问题与“避坑”指南

场景 现象/原因 解决方案
冷启动 点击按钮后,等待 1-2 秒才出现 Flutter 画面,且有白屏。 必须预热 Engine!在 App 启动时初始化 Engine 并存入 Cache。
调试 运行原生 App 后,无法使用 Flutter 的热重载 (Hot Reload)。 在终端运行 flutter attach,连接到正在运行的设备。
图片加载 Flutter 无法加载原生 Assets 中的图片。 原生图片需在 Flutter pubspec.yaml 中声明,或通过 Platform Channel 传递图片数据(字节流)。
生命周期 Flutter 页面退后台后,代码被挂起。 原生层需正确转发生命周期事件(lifecycle_channel),确保 Flutter 知道自己处于前台还是后台。

8. 总结

Add-to-App 方案打破了“非黑即白”的技术选型困境,是目前大型 App 引入 Flutter 的主流路径。

核心路径回顾:

  1. flutter create -t module 创建模块。
  2. 利用 FlutterEngineCache 解决性能问题。
  3. 利用 MethodChannel 打通数据经脉。

混合开发没有银弹,只有不断的权衡。希望本文能帮助你在现有的原生堡垒中,成功开辟出第一块 Flutter 的疆土!

延伸阅读

希望这篇分享对你有帮助!如果想了解更深层的 Engine 源码分析,欢迎留言讨论。

❌