Flutter 嵌入 Jetpack Compose 实战

掘金热榜文章以《Flutter 开始支持 Jetpack Compose Platform View》为题。但真正值得关注的不是 Compose 能否“塞进”Flutter,而是接入之后仍然存在平台视图的渲染、输入和生命周期边界;我的判断是,这项能力打开了一条互操作通道,却没有消除两套 UI 系统之间的固有成本。

Jetpack Compose 仍以 Android View 的身份进入 Flutter,完整链路是 AndroidView → PlatformViewFactory → ComposeView → Compose Composition。Flutter 负责占位、布局和触发创建,Android 侧负责组合 Compose 内容,双方再通过通道传递参数与事件。

先拆清楚 Compose 是怎样进入 Flutter 的

Flutter 并不会把 @Composable 函数转换成 Flutter Widget。实际结构可以拆成四层:

Flutter Widget 树
  └─ AndroidView(viewType: "com.example.compose/card")
          │ creationParams
          ▼
PlatformViewFactory.create(context, viewId, args)
          │ 返回 PlatformView
          ▼
ComposeView(Android View 宿主)
          │ setContent { ... }
          ▼
@Composable 函数、remember 与重组机制

Flutter 侧的 AndroidView 只是一个用于承载原生视图的 Widget。viewType 是路由键,Flutter 根据它找到 Android 侧注册的 PlatformViewFactory;creationParams 用于传递初始数据;onPlatformViewCreated 在原生视图创建完成后回传一个视图 ID。Android 侧工厂创建的 PlatformView 再通过 getView() 返回真正的 Android View,本例就是 ComposeView。

ComposeView 承担的是“Android View 宿主”职责。调用 setContent 后,Compose 开始建立自己的 Composition,拥有独立的组合、状态和重组生命周期。Flutter 中的 State 不会自动变成 Compose 的 remember,Android 侧的状态变化也不会天然触发 Flutter Widget 重建。有关 Android Platform View 的基础创建、注册和释放流程,可参阅。

这层边界也解释了为什么“支持 Compose Platform View”不等于“两套 UI 已经融合”。在技术定位上,Jetpack Compose 更偏 Android 原生、Kotlin 和声明式组合;Flutter 更偏 Dart、Widget 树和跨平台一致性。嵌入方案不是在两者之间选边,而是在 Flutter 页面中保留一个明确的原生边界。

还要区分 Jetpack Compose 与 Compose Multiplatform。前者主要解决 Android 内的 Compose 开发,后者面向跨平台共享 Compose 代码;如果需求是让同一套 Compose 代码覆盖 Android 以外的目标平台,Android Platform View 并不能替代 Compose Multiplatform。相关跨平台框架资料也把 Kotlin Multiplatform、Flutter 与 Compose 的共存方式作为独立议题。

因此,落地前需要明确三点:平台视图支持不代表任意 Compose 组件都能透明嵌入;Flutter 状态更新不代表 Compose 必然重组;单个 Android 页面可以运行,也不代表同一套交互能够自然覆盖 iOS。

最小接入要同时处理通信、尺寸与生命周期

开始编码前,先准备可运行的 Flutter Android 宿主。官方安装流程包括安装 Flutter SDK、配置目标平台和编辑器,再通过 flutter doctor 等工具检查环境。项目内还需要核对 Android Gradle Plugin、Kotlin、Compose 编译器与 Compose BOM 的版本兼容性,具体组合应按当前官方兼容矩阵确定,而不是从旧项目复制版本号。

下面给出一套最小骨架。Kotlin 侧注册工厂、创建 ComposeView 并处理 Flutter 命令:

package com.example.composehost

import android.content.Context
import android.view.View
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory

private const val VIEW_TYPE = "com.example.compose/card"
private const val CHANNEL_PREFIX = "com.example.compose.view"

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        flutterEngine.platformViewsController.registry.registerViewFactory(
            VIEW_TYPE,
            ComposeCardFactory(flutterEngine.dartExecutor.binaryMessenger)
        )
    }
}

private class ComposeCardFactory(
    private val messenger: BinaryMessenger
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {

    override fun create(
        context: Context,
        viewId: Int,
        args: Any?
    ): PlatformView {
        return ComposeCardView(context, messenger, viewId, args)
    }
}

private class ComposeCardView(
    context: Context,
    messenger: BinaryMessenger,
    viewId: Int,
    args: Any?
) : PlatformView {

    private val initialCount =
        (args as? Map<*, *>)?.get("count") as? Int ?: 0

    private val countState = mutableStateOf(initialCount)
    private val channel =
        MethodChannel(messenger, "$CHANNEL_PREFIX.$viewId")

    private var disposed = false

    private val composeView = ComposeView(context).apply {
        setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnDetachedFromWindow
        )

        setContent {
            ComposeCardContent(
                count = countState.value,
                onSelected = { value ->
                    if (!disposed) {
                        channel.invokeMethod(
                            "itemSelected",
                            mapOf("value" to value)
                        )
                    }
                }
            )
        }
    }

    init {
        channel.setMethodCallHandler { call, result ->
            when (call.method) {
                "setCount" -> {
                    val count = call.argument<Int>("count")
                    if (count == null) {
                        result.error(
                            "INVALID_COUNT",
                            "count is required",
                            null
                        )
                    } else {
                        countState.value = count
                        result.success(null)
                    }
                }

                else -> result.notImplemented()
            }
        }
    }

    override fun getView(): View = composeView

    override fun dispose() {
        disposed = true
        channel.setMethodCallHandler(null)
        composeView.disposeComposition()
    }
}

@Composable
private fun ComposeCardContent(
    count: Int,
    onSelected: (String) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        Text("当前计数:$count")

        Button(
            onClick = {
                expanded = !expanded
                onSelected(if (expanded) "expanded" else "collapsed")
            }
        ) {
            Text("切换详情")
        }

        if (expanded) {
            Text("展开状态由 Compose 独立管理")
        }
    }
}

Dart 侧通过 AndroidView 创建原生视图,并通过方法通道发送命令、接收事件:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class ComposeCardHost extends StatefulWidget {
  const ComposeCardHost({
    super.key,
    required this.count,
    required this.onSelected,
  });

  final int count;
  final ValueChanged<String> onSelected;

  @override
  State<ComposeCardHost> createState() => _ComposeCardHostState();
}

class _ComposeCardHostState extends State<ComposeCardHost> {
  MethodChannel? _channel;

  @override
  void didUpdateWidget(covariant ComposeCardHost oldWidget) {
    super.didUpdateWidget(oldWidget);

    final channel = _channel;
    if (channel != null && oldWidget.count != widget.count) {
      channel.invokeMethod<void>(
        'setCount',
        <String, int>{'count': widget.count},
      );
    }
  }

  Future<dynamic> _handleNativeCall(MethodCall call) async {
    if (call.method != 'itemSelected' || !mounted) {
      return null;
    }

    final arguments =
        Map<Object?, Object?>.from(call.arguments as Map<dynamic, dynamic>);

    widget.onSelected(arguments['value'] as String? ?? '');
    return null;
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 180,
      child: AndroidView(
        viewType: 'com.example.compose/card',
        creationParams: <String, int>{'count': widget.count},
        creationParamsCodec: const StandardMessageCodec(),
        onPlatformViewCreated: (int viewId) {
          final channel =
              MethodChannel('com.example.compose.view.$viewId');

          _channel = channel;
          channel.setMethodCallHandler(_handleNativeCall);

          channel.invokeMethod<void>(
            'setCount',
            <String, int>{'count': widget.count},
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    _channel?.setMethodCallHandler(null);
    _channel = null;
    super.dispose();
  }
}

这段代码体现了三个原则。第一,creationParams 只负责初始值,后续更新通过 MethodChannel 发送;第二,Flutter 侧的 count 是业务状态的事实来源,而 Compose 内部的 expanded 只是局部界面状态;第三,每个平台视图按 viewId 使用独立通道,避免多个实例争抢同一个方法调用处理器。

实际项目也可以用底部导航、选择面板或确认卡片验证事件回传。底部导航是较常见的 Compose UI 案例,可以参考其组件组织方式,但相关实现只用于理解 Compose 界面,不能替代 Flutter 与 Compose 的通信设计。

通信协议最好保持简单:Flutter 到 Compose 传递参数和命令,Compose 到 Flutter 回传选择、提交、取消等离散事件。高频状态不应随着每次重组直接跨通道往返,而应在原生侧合并、节流或按帧批量发送。对于输入草稿、弹窗展开等临时状态,保留在 Compose 内部更合理;如果同一字段同时存在于 Dart State 和 Compose State 中,就必须明确唯一事实来源,否则迟早会出现两边显示不一致的问题。

尺寸问题往往比通信更容易造成线上问题。固定宽高最稳定,例如示例中的 SizedBox(height: 180)。如果 Compose 内容需要动态高度,应先由原生侧测量,再把高度回传 Flutter 并更新外层约束,同时设置最大高度和防循环机制,避免“原生高度变化—Flutter 重建—再次测量”的反馈循环。

多实例场景中,viewType 用于选择工厂,viewId 才表示具体实例。不同布局可以注册不同 viewType,也可以通过 creationParams 分支处理。平台视图创建成本通常高于普通 Flutter Widget,因此不宜在长列表中为每个条目都创建复杂的 Compose 页面;如果列表滚动会销毁并重建原生视图,Compose 的 remember 状态也可能丢失,需要把必须保留的数据提升到 Flutter 或原生业务层。

生命周期处理不能只依赖 setContent。视图移除时,应在 dispose 中释放 Composition、通道处理器和监听器;Activity 重建、应用进入后台、页面切换后,也要确认平台视图是重新创建、重新绑定还是继续保留。Flutter Hot Reload 更新的是 Dart 代码,并尽量保留运行时状态,它不等同于 Compose 代码热更新。Compose 代码通常需要重新构建运行,只有通过通道主动同步的数据才会立即反映到另一侧。

上线前要逐项检查兼容与体验边界

平台视图支持解决的是“能不能接入”,生产可用性则取决于版本组合、合成路径、输入、焦点、窗口内容、无障碍和资源成本。下面这张表应当作为发布前检查清单,而不是等用户反馈后再逐项补救。

检查项 上线前需要验证的内容 常见风险信号
版本组合 按发布时官方矩阵核对 Flutter Platform View 能力、Android embedding、minSdk/targetSdk/compileSdk、Kotlin、AGP、Compose 编译器及 BOM 编译通过但低版本设备异常,或 Compose 编译器与 Kotlin 版本不匹配
渲染与层级 验证圆角、裁剪、透明度、多个 Platform View 的前后顺序,以及 Flutter Widget 与原生视图重叠时的表现 圆角丢失、原生视图遮住 Flutter 兄弟组件、切换合成路径后层级变化
手势与滚动 测试点击、长按、拖拽、Flutter 手势竞争、嵌套滚动和滚动物理效果 外层 Flutter 列表与内层 Compose 列表互相抢事件,或滚动时出现卡顿与跳变
文本与焦点 验证 Compose TextField 的焦点、软键盘、选区、光标,以及 Flutter 页面切换时的输入行为 键盘不弹出、输入法重复、焦点落到不可见控件
弹窗与浮层 检查 Dialog、Popup、Tooltip 等内容的窗口归属、裁剪范围和前后层级 Dialog 越过 Platform View 边界,Popup 被裁剪或显示在错误层级
生命周期 覆盖旋转屏幕、切后台、页面返回、进程重建、Activity 重建,以及多个页面同时持有原生视图的情况 页面返回后通道重复注册、Compose 状态丢失或旧视图继续响应事件
性能 记录平台视图创建耗时、纹理内存、Compose 重组频率和通道消息量 首屏创建时掉帧,高频消息导致通道拥塞,长列表内存持续增长
无障碍 检查 Compose 语义节点、焦点顺序、屏幕阅读器文案,以及 Flutter 与 Compose 两侧的可访问性树 控件无法聚焦,读屏文案缺失或重复朗读
平台范围 明确该方案只解决 Android 嵌入,iOS 使用独立实现 Android 验证通过后直接假设 iOS 行为一致

渲染差异尤其需要真机验证。不同 Flutter 版本、Android API 和视图类型可能采用不同的平台视图合成路径,有些场景更接近原生层级合成,有些则需要纹理回退。两条路径对透明度、裁剪、圆角、遮挡和内存占用的表现并不完全相同,不能用一次模拟器截图代替完整测试。

输入也存在类似的边界问题。原生 View 消费触摸事件后,Flutter 的手势识别器未必能同时获得完整事件流;“外层 Flutter 列表、内层 Compose 列表”尤其容易出现滚动锁定或事件竞争。文本输入还涉及 Flutter 文本输入通道、Android 焦点系统和 Compose 焦点管理,测试时不能只确认字符最终出现,还要检查光标、选区、软键盘和页面切换行为。

Compose 的 Dialog、Popup 和 Tooltip 不一定被限制在普通 View 的绘制边界内。Dialog 可能使用独立 Window,Popup 则可能依赖 Compose 的绘制层级,因此要单独验证它们是否会越界、被裁剪或显示在其他 Flutter 组件之上。不能因为主体内容已经成功嵌入,就推断所有浮层组件也会自动适配。

性能评估也不能套用 Flutter Widget 的成本模型。平台视图通常有独立的创建、原生布局和资源释放过程,纹理回退还可能增加内存占用。跨平台混合开发资料同样把集成方案与性能优化列为核心关注点;实践上应重点观察创建卡顿、重组频率、通道消息量和长列表中的实例数量。

选型上,小而稳定、确实依赖 Android 原生能力的叶子组件更适合嵌入,例如展示型卡片、标签或局部状态面板。整页 Compose、复杂导航、跨端一致性要求很高,或者频繁依赖 Dialog、Popup 等窗口内容时,优先考虑直接使用 Flutter 实现,或者把复杂部分保留为独立原生页面,通过平台通道完成页面跳转。

发布前至少应覆盖不同 Android API 层级、真机、Debug/Profile/Release 三种构建模式、旋转与前后台切换、软键盘、屏幕阅读器,以及多个平台视图共存场景。Profile 和 Release 模式下的帧率、内存与创建耗时,往往比 Debug 模式更能反映真实问题。

我的判断是,Jetpack Compose Platform View 提供的是局部互操作能力,并未构成 Compose 与 Flutter 的统一运行时。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容