Compose 与原生 UI 混排原理解析

559 阅读10分钟

前言

在跨平台开发中,尽管 Compose Multiplatform 提供了统一的 UI 范式,但在某些场景下仍然需要与平台原生 UI 协同开发。例如,项目中已有成熟的原生 UI 组件,直接重写成本高且可能影响稳定性,或者某些功能(如 WebView、地图、视频播放器等)依赖平台原生实现,无法用 Compose 直接替代。

为了满足这类需求,Compose 在 Android、iOS、Desktop 等平台上提供了原生 UI 组件与 Compose 互相嵌套(即混排)的能力,使开发者能够在 Compose 界面中嵌入原生视图,兼顾跨平台一致性与平台特性。

本文将重点介绍 Android 和 iOS 平台上 Compose 如何嵌套原生 UI,并深入解析其底层实现原理,帮助开发者更好地理解 Compose 的混排机制。

Android

使用方式

在 Android 上 Compose 提供了AndroidView来实现与原生 View 的混排,AndroidView 是一个 Composable 方法, 核心参数如下:

  • factory: 用于创建嵌入到 Compose 中的 Android View
  • modifier: 应用到当前Compose 组件 AndroidView的 Modifier,可以用于设置组件大小以及背景颜色等
  • update: 在 View 创建后以及后续 Compose 重组时的回调,在该回调中可以更新 Android View 的状态
@Composable
fun <T : View> AndroidView(
    factory: (Context) -> T,
    modifier: Modifier = Modifier,
    onReset: ((T) -> Unit)? = null,
    onRelease: (T) -> Unit = NoOpUpdate,
    update: (T) -> Unit = NoOpUpdate
) {...}

下面的代码演示了如何在 Compose 中嵌入一个 Android TextView 以及对应效果

原理解析

在分析混排原理之前,我们需要了解一些前置的知识点。

  • Compose 实际管理的是一颗 LayoutNode 树, LayoutNode 负责处理和分发布局、绘制、手势等事件
  • 在 Android 上 Compose的容器是AndroidComposeView,它是一个 ViewGroup 并持有 Compose 的 Root LayoutNode,负责将 View 系统的测量、布局、绘制、触摸事件分发给 Root LayoutNode

AndroidView 实现混排的核心原理有以下两点:

  • 为混排 View 创建一个父 ViewGroup 和与之关联的 LayoutNode,并将该 ViewGroup 添加到AndroidComposeView
  • 将与之关联 LayoutNode 的测量、布局、绘制、触摸事件全部分发给 ViewGroup,实现混排效果

添加 View

AndroidView 构造的 ViewGroup 是 AndroidViewHolder,在初始化时添加混排 View 作为子 View,并在 LayoutNode的onAttach时机将自身添加到 AndroidComposeView

@Composable
fun <T : View> AndroidView(...) {
    // ...
    ReusableComposeNode<LayoutNode, UiApplier>(
        factory = createAndroidViewNodeFactory(factory),
        // ...
    )
}

@Composable
private fun <T : View> createAndroidViewNodeFactory(
    factory: (Context) -> T
): () -> LayoutNode {

    return {
        // 创建 ViewFactoryHolder 并返回 layoutNode
        ViewFactoryHolder(
            // ...
        ).layoutNode
    }
}

// ViewFactoryHolder核心逻辑都在 AndroidViewHolder 中
internal class ViewFactoryHolder: AndroidViewHolder {...}

internal open class AndroidViewHolder {
    init {
        // 将混排 View 添加为子 View
        addView(view)
    }
    
    // 创建关联的 LayoutNode
    val layoutNode: LayoutNode = run {
        val layoutNode = LayoutNode()
        layoutNode.onAttach = { owner ->
            // 将自身添加到 AndroidComposeView 中
            (owner as? AndroidComposeView)?.addAndroidView(this, layoutNode)
            if (view.parent !== this) addView(view)
        }
        layoutNode
    }
}

Measure & Layout

虽然混排 View 已经被添加到 AndroidComposeView 中,然而AndroidComposeView并不像一般的 ViewGroup 那样会分发 measure/layout 到子 View,而是将事件全部分发到 Compose 中。

为了正确测量混排 View 的大小和位置,AndroidViewHolder通过为 LayoutNode设置measurePolicy将 Compose 的测量流程转发到混排 View 上

internal open class AndroidViewHolder {

    val layoutNode: LayoutNode = run {
        val layoutNode = LayoutNode()
        layoutNode.measurePolicy = object : MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints
            ): MeasureResult {
                // ...
                // 将 Compose 的 Constraints 转换为 MeasureSpec 并触发自身的 Measure
                measure(
                    obtainMeasureSpec(
                        constraints.minWidth,
                        constraints.maxWidth,
                        layoutParams!!.width
                    ),
                    obtainMeasureSpec(
                        constraints.minHeight,
                        constraints.maxHeight,
                        layoutParams!!.height
                    )
                )
                return layout(measuredWidth, measuredHeight) {
                    // 触发 layout
                    layoutAccordingTo(layoutNode)
                }
            }
        }
        layoutNode
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 触发混排 View 的 measure,并设置自身的大小与混排 View 一致
        view.measure(widthMeasureSpec, heightMeasureSpec)
        setMeasuredDimension(view.measuredWidth, view.measuredHeight)
    }
    
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 触发混排 View 的 layout
        view.layout(0, 0, r - l, b - t)
    }
}

Draw

同理 AndroidComposeView 也不会正常向子 View 分发绘制流程,AndroidViewHolder通过向 LayoutNode 的 Modifier 添加 drawBebind 将绘制流程转发到混排 View 上

internal open class AndroidViewHolder {

    val layoutNode: LayoutNode = run {
        val layoutNode = LayoutNode()
        val coreModifier = Modifier
            .drawBehind {
                 drawIntoCanvas { canvas ->
                     if (view.visibility != GONE) {
                         isDrawing = true
                         (layoutNode.owner as? AndroidComposeView)
                            ?.drawAndroidView(this@AndroidViewHolder, canvas.nativeCanvas)
                         isDrawing = false
                    }
                }
            }
        layoutNode
    }
}

drawAndroidView最终会直接调用 view.draw 方法触发绘制流程

class AndroidComposeView {
    fun drawAndroidView(view: AndroidViewHolder, canvas: android.graphics.Canvas) {
        androidViewsHandler.drawView(view, canvas)
    }
}

class AndroidViewsHandler {
    fun drawView(view: AndroidViewHolder, canvas: Canvas) {
        view.draw(canvas)
    }
}

Touch

触摸事件的分发通过pointerInteropFilter实现

internal open class AndroidViewHolder {

    val layoutNode: LayoutNode = run {
        val layoutNode = LayoutNode()
        val coreModifier = Modifier
            .pointerInteropFilter(this)
        layoutNode
    }
}

internal fun Modifier.pointerInteropFilter(view: AndroidViewHolder): Modifier {
    val filter = PointerInteropFilter()
    filter.onTouchEvent = { motionEvent ->
        when (motionEvent.actionMasked) {
            ACTION_DOWN,
            ACTION_POINTER_DOWN,
            ACTION_MOVE,
            ACTION_UP,
            ACTION_POINTER_UP,
            ACTION_OUTSIDE,
            ACTION_CANCEL -> view.dispatchTouchEvent(motionEvent) // 转发触发事件到混排 View
            // ACTION_HOVER_ENTER,
            // ACTION_HOVER_MOVE,
            // ACTION_HOVER_EXIT,
            // ACTION_BUTTON_PRESS,
            // ACTION_BUTTON_RELEASE,
            else -> view.dispatchGenericMotionEvent(motionEvent)
        }
    }
    return this.then(filter)
}

iOS

使用方式

在 iOS 上 Compose 也提供了和 Android 类似的 Composable 方法UIKitView,其参数设计与 AndroidView 类似:

  • factory: 用于创建嵌入到 Compose 中的 UIView
  • modifier: 应用到当前Compose 组件 UIKitView的 Modifier,可以用于设置组件大小以及背景颜色等
  • update:UIView 创建后以及Compose重组时触发的回调,可用于更新 UIView 的状态
  • background:用于设置组件的背景颜色
  • interactive:控制 UIView 是否消费用户触摸事件
@Composable
fun <T : UIView> UIKitView(
    factory: () -> T,
    modifier: Modifier,
    update: (T) -> Unit = STUB_CALLBACK_WITH_RECEIVER,
    background: Color = Color.Unspecified,
    onRelease: (T) -> Unit = STUB_CALLBACK_WITH_RECEIVER,
    onResize: (view: T, rect: CValue<CGRect>) -> Unit = DefaultViewResize,
    interactive: Boolean = true,
) {...}

下面的代码演示了如何在 Compose 中嵌入一个 UITextView 以及对应效果

原理解析

基于 Compose Multiplatform 1.6.11 分析

由于 iOS 上 UIView 的渲染机制与 Android 上的 Canvas 不同,所以无法采用类似 AndroidView 的方案实现混排。因此,Compose 在 iOS 上采用了一种比较取巧的“挖洞”方案来实现:

  • 在 Compose 容器的底层添加一个 UIView 容器 InteropContainer,所有通过 UIKitView 创建的 UIView 都会被添加到这一层
  • 监听UIKitView的尺寸和位置信息,并同步调整 UIView 在 InteropContainer 中的大小和位置
  • 在 Compose 层将 UIKitView 对应的区域绘制为透明,使其下方 InteropContainer 中的 UIView 可见(即“挖洞”)

这一机制听起来可能有些复杂,但通过 Xcode 查看 View 层级,整个流程就一目了然了。

添加 UIView

在 iOS 上 Compose 容器的配置逻辑在 ComposeSceneMesiator

class ComposeSceneMediator {
    init {
        // 省略其余代码
        rootView.addSubview(interopViewContainer.containerView)
        rootView.addSubview(interactionView)
        interactionView.addSubview(renderingView)
    }
    
    @OptIn(InternalComposeApi::class)
    @Composable
    private fun ProvideComposeSceneMediatorCompositionLocals(content: @Composable () -> Unit) =
        CompositionLocalProvider(
            LocalUIKitInteropContainer provides interopViewContainer,
            // ...
            content = content
        )
}

核心逻辑是依次向容器中添加 InteropContainerInteractionViewRenderingView

  • InteractionViewRenderingView 负责 Compose 的绘制与事件交互。
  • InteropContainer 作为 UIKitView 提供的 UIView 的承载容器,是实现视图混排的关键组件。

同时ComposeSceneMediator在 Compose 的根节点设置了LocalUIKitInteropContainer,使得 Composable 组件可以获取 InteropContainer 并向其中添加 UIView

@Composable
fun <T : UIView> UIKitView(...) {
    val interopContainer = LocalUIKitInteropContainer.current
    // 使用 EmbeddedInteropViewController 封装 UIVIew
    val embeddedInteropComponent = remember {
        EmbeddedInteropViewController(
            interopContainer = interopContainer,
            rootViewController = rootViewController,
            onRelease = onRelease
        )
    }
    
    DisposableEffect(Unit) {
        // 创建 UIView
        embeddedInteropComponent.component = factory()
    
        interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_ADDED) {
            // 将 UIVIew 添加到 InteropContainer 中
            embeddedInteropComponent.addToHierarchy()
        }
    
        onDispose {
            interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_REMOVED) {
                // Dispose 时将 UIView 从 InteropContainer 中移除
                embeddedInteropComponent.removeFromHierarchy()
            }
        }
    }
}

挖洞显示 UIView

为了让 InteropContainer 中的 UIView 正确显示,UIKitView 实现了以下逻辑:

  • 将自身所在的区域设置为透明,使底层 InteropContainer 内的 UIView 可见。
  • onGloballyPositioned 回调中,动态更新 UIView 的尺寸,并调整其相对于根节点的位置,从而确保 UIView 能准确覆盖 UIKitView 的区域。
@Composable
fun <T : UIView> UIKitView(...) {
    // ...
    
    EmptyLayout(
        modifier.onGloballyPositioned { coordinates ->
            localToWindowOffset = coordinates.positionInRoot().round()
            val newRectInPixels = IntRect(localToWindowOffset, coordinates.size)
            if (rectInPixels != newRectInPixels) {
                // 计算组件位置
                val rect = newRectInPixels.toRect().toDpRect(density)
        
                interopContext.deferAction {
                    // 设置组件 Frame
                    embeddedInteropComponent.wrappingView.setFrame(rect.asCGRect())
                }
                rectInPixels = newRectInPixels
            }
        }.drawBehind {
            // Clear interop area to make visible the component under our canvas.
            // 绘制透明色
            drawRect(Color.Transparent, blendMode = BlendMode.Clear)
        }
    )
    
    // ...
}

设置背景

由于 UIKitView 通过 drawBehind 将自身区域设为透明,因此开发者在 UIKitView 上使用 Modifier.background 并不会生效。为了支持组件的背景设置,UIKitView 额外提供了一个 background 参数,允许开发者直接指定背景颜色。

@Composable
fun <T : UIView> UIKitView(...) {
    // ...
    
    LaunchedEffect(background) {
        interopContext.deferAction {
            embeddedInteropComponent.setBackgroundColor(background)
        }
    }
    
    // ...
}

abstract class EmbeddedInteropComponent {
    fun setBackgroundColor(color: Color) {
        if (color == Color.Unspecified) {
            wrappingView.backgroundColor = interopContainer.containerView.backgroundColor
        } else {
            wrappingView.backgroundColor = color.toUIColor()
        }
    }
}

实际上,background 参数的颜色并不是直接应用在 UIKitView 上,而是设置在 wrappingView 上。那么,wrappingView 是什么呢?

在 Compose 中,UIView 并不会被直接添加到 InteropContainer,而是先创建一个 wrappingView 作为 UIView 的父视图,并将该 wrappingView 添加到 InteropContainer 中。这样一来,background 作用于 wrappingView,而不会影响 UIView 本身的背景设置,从而确保 UIView 的外观可控且不受干扰。

事件分发

尽管通过挖洞能够显示出 UIView,但是这块区域的触摸事件仍然是由 Compose 消费。前面提到过负责事件交互的是InteractionView,在InteractionViewpointInside方法中会判断当前事件是否应该由 UIView 消费

class ComposeSceneMediator {
    // 创建 interactionView 并设置 touchDelegate
    private val interactionView by lazy {
        InteractionUIView(
            // ...
            touchesDelegate = touchesDelegate,
            // ...
        )
    }
    
    private val touchesDelegate: InteractionUIView.Delegate by lazy {
        object : InteractionUIView.Delegate {
            override fun pointInside(point: CValue<CGPoint>, event: UIEvent?): Boolean =
                point.useContents {
                    val position = this.asDpOffset().toOffset(density)
                    !scene.hitTestInteropView(position)
                }
        }
    }
}

internal class RootNodeOwner {
    fun hitTestInteropView(position: Offset): Boolean {
        val result = HitTestResult()
        owner.root.hitTest(position, result, true)
        val last = result.lastOrNull()
        return (last as? BackwardsCompatNode)?.element is InteropViewCatchPointerModifier
    }
}

最终的判断逻辑在 RootNodeOwner#hitTestInteropView中,其流程如下:

  • 通过 owner.root.hitTest 获取所有命中当前位置的PointerInputModifier
  • 判断该 Modifer 是否是InteropViewCatchPointerModifier,如果是则拦截该事件,将事件传递给下层 UIView

UIKitView提供了一个参数interactive用来控制 UIView 是否需要响应事件,如果设置为 true 则会为当前的 Modifier 添加一个InteropViewCatchPointerModifier,这就是为什么会通过判断当前响应的PointerInputModifier是否InteropViewCatchPointerModifier来确定事件分发逻辑

@Composable
fun <T : UIView> UIKitView(
    // ...
    interactive: Boolean = true,
) {
    EmptyLayout(
        // ...
        modifier.let {
         if (interactive) {
                it.then(InteropViewCatchPointerModifier())
            } else {
                it
            }
        }
    )
}

UIKitView 顺序

在 Compose 中,组件可以根据条件语句动态增删,而 UIView 则是在 UIKitView 首次组合时添加到 InteropContainer 中。如果不加处理,可能会导致多个 UIView 之间的顺序错乱。

例如,在以下代码示例中:

  • 绿色的 UIView 先添加到 InteropContainer 中。
  • 点击按钮后,红色的 UIView 再添加到 InteropContainer 中。

如果没有额外的顺序管理,红色的 UIView 会直接覆盖在绿色 UIView 之上,而这并不符合我们的预期。

@Composable
fun InteropOrder() {
    var showRed by remember { mutableStateOf(false) }
    Column(
        modifier = Modifier.padding(10.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp)
    ) {
        Button(onClick = { showRed = true } ) {
            Text("Show Red")
        }

        Box {
            if (showRed) {
                // 红色背景 UIView
                UIKitView(
                    factory = { UIView().apply { backgroundColor = UIColor.redColor } } ,
                    modifier = Modifier.size(150.dp).offset(0.dp, 0.dp),
                )
            }
            // 绿色背景 UIView
            UIKitView(
                factory = { UIView().apply { backgroundColor = UIColor.greenColor } } ,
                modifier = Modifier.size(150.dp).offset(75.dp, 75.dp),
            )
        }
}
}

然而实际效果是绿色 UIView 在红色 UIView 之上,这是因为 Compose 对 UIKitVIew 在 Z 轴上的顺序做了处理

UIKitInteropContainer 在添加 UIView 之前计算了该 UIView 的顺序,并将该 UIView 插入到指定的 Index 上。

internal class UIKitInteropContainer: InteropContainer<UIView> {
    override var rootModifier: TrackInteropModifierNode<UIView>? = null
    
    override fun addInteropView(nativeView: UIView) {
        // 计算 Index
        val index = countInteropComponentsBefore(nativeView)
        interopViews.add(nativeView)
        // 插入到指定 Index
        containerView.insertSubview(nativeView, index.toLong())
    }
}

internal fun <T> InteropContainer<T>.countInteropComponentsBefore(nativeView: T): Int {
    var componentsBefore = 0
    rootModifier?.traverseDescendants {
        if (it.nativeView != nativeView) {
            // It might be inside Compose tree before adding in InteropContainer in case
            // if it was initiated out of scroll visible bounds for example.
            if (it.nativeView in interopViews) {
                componentsBefore++
            }
            ContinueTraversal
        } else {
            CancelTraversal
        }
    }
    return componentsBefore
}

计算逻辑为按顺序遍历 rootModifier 的子 Modifier,找到对应 UIView 所在的 Index,该 Modifier 类型为TrackInteropModifierNode。之所以能够通过这个 Modifier 来匹配 UIView,是因为 UIKitView 会主动添加TrackInteropModifierNode并设置 nativeView

@Composable
fun <T : UIView> UIKitView(
    // ...
    interactive: Boolean = true,
) {
    EmptyLayout(
        // ...
        modifier.trackUIKitInterop(embeddedInteropComponent.wrappingView)
    )
}

internal fun Modifier.trackUIKitInterop(
    view: UIView
): Modifier = this then TrackInteropModifierElement(
    nativeView = view
)

方案限制

受限于这种较为取巧的实现方式,iOS 上的混排能力有一些已知的缺陷

UIView 设置透明背景

在混排时如果为 UIView 设置透明背景,将会出现与预期不符的效果。如下面的例子,在两个 UIView 中放置一个蓝色的 ComposeView,效果如图

如果给绿色的 UIView 加一点透明度,效果则会变成这样,而不是我们预期的蓝色方块覆盖在红色方块上

这是因为Compose 的蓝色方块和两个 UIVIew不在一个层级上,Compose 层面已经将绿色UIView所在区域设置为透明,如果绿色方块不展示则会漏出下面的红色 UIView,看一下 View 层级会更加清晰

wrapContentSize 无效

使用 UIKitView 需要显示指定 size 或者使用 fillMaxSize,如果使用 wrapContentSize 则会导致 UIVIew 大小为 0 从而无法显示。这是因为 UIKitView 内部使用了 EmptyLayoutEmptyLayout默认测量策略为取constraints最小值作为组件尺寸

@Composable
internal fun EmptyLayout(modifier: Modifier) = Layout(
    content = {} ,
    modifier = modifier,
    measurePolicy = { _, constraints ->
        layout(constraints.minWidth, constraints.minHeight)  {} 
    }
)