【Android性能优化】LeakCanary工具的原理解析

【Android性能优化】LeakCanary工具的原理解析

本文介绍了性能测试和性能优化的方法论和实例

LeakCanary 是 Square 公司开源的一款用于检测 Android 应用中 内存泄漏(Memory Leak) 的自动化工具。它能够在应用运行时自动检测内存泄漏,尤其是像 Activity、Fragment 等组件的泄漏,并在发现泄漏时通过通知提醒开发者,同时提供详细的泄漏引用链信息,帮助开发者快速定位问题。

工作原理

主要分为以下几个主要阶段:

1. 监控 Activity 和 Fragment 的生命周期

在 Application 类中,通常会调用 LeakCanary.install(this)。这是 LeakCanary 的入口点。(在2.0版本已经实现了隐式调用,无需手动调用install方法)

LeakCanary 2.0 利用了 Android 的 ContentProvider 自动初始化机制,通过在库中注册一个内部的 LeakCanaryInstaller ContentProvider,系统会在 Application.onCreate() 之前自动初始化它。这样设计有几个好处:一是简化了集成流程,开发者 只需添加依赖 即可;二是实现了自动按需初始化,只在 debug 构建中工作;三是遵循了现代 Android 库的设计趋势。这种改变使得内存泄漏检测对开发者更加透明和无侵入。

LeakCanary 通过注册 Application.ActivityLifecycleCallbacksFragmentManager.FragmentLifecycleCallbacks 来监听所有 Activity 和 Fragment 的生命周期事件。

在这期间,还会初始化后台线程池。LeakCanary 会创建一个专门的后台线程池来执行耗时的操作,例如后面的堆转储操作,以避免阻塞主线程。初始化通知管理器,用于在检测到泄漏或进行堆转储时显示通知。

2. 检测内存泄漏

监听onDestroy回调

以Activity为例,当用户退出一个 Activity 时, ActivityonDestroy() 方法会被调用。

由于 LeakCanary 注册了 Application.ActivityLifecycleCallbacks ,它会接收到这个 Activity 的 onDestroyed 回调。

在收到 onDestroyed 回调通知后,LeakCanary 会对即将被销毁的 Activity 对象创建一个特殊的 KeyedWeakReference 。这个 KeyedWeakReference 不仅仅是一个普通的弱引用,它还包含一个唯一的 key 和一些元数据(如 Activity 的类名、创建时间等),用于在后续分析中识别对象。

这个 KeyedWeakReference 会被添加到 LeakCanary 内部的一个 ObjectWatcher 维护的观察列表中。

检查弱引用观测列表

LeakCanary 内部有一个周期性的任务,会定期在后台线程中运行。在这个任务中,LeakCanary 会主动调用 System.gc() 来触发一次垃圾回收。

需要注意的是,System.gc() 只是建议 JVM 进行垃圾回收,并不能保证立即执行或完全清除所有可回收对象。 LeakCanary 会多次尝试 GC,以提高清除弱引用的概率。

在 GC 之后,LeakCanary 会遍历之前创建的集合,查看其中的弱引用是否已经被清除。

  • 弱引用已清除: 如果 KeyedWeakReference.get() 返回 null,说明它引用的 Activity 对象已经被垃圾回收了。这表示 Activity 正常地被销毁, 没有发生内存泄漏 。这个 KeyedWeakReference 就会从集合中移除。
  • 弱引用未清除(被保留): 如果 KeyedWeakReference.get() 仍然返回 非 null ,说明它引用的 Activity 对象仍然存在于内存中,它其实应该是被销毁的。此时,LeakCanary 就认为这个 Activity 对象被“保留(retained)”了,并且很可能发生了内存泄漏。 LeakCanary 会在 Logcat 中打印一条信息,指示哪个 Activity 被保留了。

3. 触发堆转储

LeakCanary 会统计被保留对象的数量。默认情况下,当被保留对象的数量达到 5个(可配置)时,LeakCanary 会触发一次堆转储。这是为了避免频繁的堆转储对用户体验造成影响。

应用在后台: 如果应用进入后台,LeakCanary 会更积极地触发堆转储,默认情况下,只要监测到被保留对象时就会触发。因为在后台时,堆转储对用户体验的影响较小。

当触发堆转储时,LeakCanary 会显示一个 Toast 提示用户,同时在通知栏显示一个进度通知。

LeakCanary 会调用 Debug.dumpHprofData(filePath) 方法将当前 Java 堆的完整快照保存为一个 .hprof 文件到应用的私有存储空间。这个过程是一个耗时操作,会短暂地阻塞应用的主线程。

堆转储对于内存泄漏分析至关重要,但它确实是一个资源密集型操作,对应用性能有显著影响,主要因为 性能开销高 ,堆转储需要遍历和记录应用程序内存中的所有可达对象。对于大型应用或包含大量对象的应用,这个过程会涉及大量的 CPU 计算和 I/O 操作。在执行堆转储时,Java 虚拟机通常需要暂停所有应用线程(”Stop-The-World”)以确保内存状态的稳定性和一致性。这意味着你的应用会暂时失去响应,UI 会卡顿甚至冻结几秒钟,用户体验会受到严重影响。将整个内存快照写入 .hprof 文件是一个大量的磁盘写入操作。

堆转储 过程本质上是把整个应用程序的内存快照保存到一个文件中,然后对其进行分析。具体来说,堆转储会执行以下关键任务:

  • 停止应用进程,为了确保内存快照的完整性和一致性,确保所有对象的状态在转储时是静态的。
  • 遍历所有可达对象,Java 虚拟机(JVM)会遍历当前进程内存中所有 可达 (reachable) 的对象。这意味着从根对象(如线程栈、静态变量等)开始,沿着对象引用图遍历所有可以访问到的对象。
  • 记录对象信息,对于每个遍历到的对象,堆转储会记录其重要信息,包括对象的类名,大小,字段值,引用关系。
  • 将内存快照写入文件 (.hprof):所有这些对象信息会被序列化并写入一个特定的文件格式,通常是 .hprof (Heap PROFile) 文件。

4. 分析堆转储文件与展示

为了不影响应用的主进程,LeakCanary 会在一个 独立的后台进程 中启动一个服务(HeapAnalyzerService)来处理 .hprof 文件的分析。这样做的好处是即使堆分析崩溃或出现内存问题,也不会影响到应用本身。

在分析进程中,LeakCanary 会查找那些 应该被垃圾回收但仍然被引用的对象 。它会逆向追溯引用链,找出导致对象无法被回收的“罪魁祸首”(即泄漏路径)。最后,LeakCanary 会通过通知或其他方式向你报告发现的内存泄漏,并提供详细的引用链,帮助你定位问题。

对堆转储文件的分析会使用 LeakCanary 内部的 Shark 库来解析 .hprof 文件。

  • 查找 GC Roots:Shark 会首先识别出所有的 GC Roots(垃圾回收的根对象)。
  • 遍历对象图:从 GC Roots 开始,Shark 会遍历整个对象图,查找所有可达的对象
  • 定位被保留对象: Shark 会通过之前 KeyedWeakReference 中存储的 key 来定位到之前被标记为 “被保留” 的对象。
  • 计算最短强引用路径: 这是分析的核心。Shark 会从 GC Roots 到被保留对象之间,计算出最短的强引用路径。这个路径就是导致泄漏的“泄漏跟踪(leak trace)”。它会显示哪些对象持有对泄漏对象的强引用,直到某个 GC Root。
  • 过滤已知泄漏:LeakCanary 内置了一些规则,可以识别并忽略一些 Android 框架内部的已知泄漏,避免误报。
  • 识别可疑点: LeakCanary 会尝试根据泄漏跟踪识别出最可能导致泄漏的代码位置或对象类型。
哪些可以作为GC Roots的对象呢?Java 语言中包含了如下几种:
        1)虚拟机栈(栈帧中的本地变量表)中的引用的对象。
        2)方法区中的类静态属性引用的对象。
        3)方法区中的常量引用的对象。
        4)本地方法栈中JNI(即一般说的Native方法)的引用的对象。
        5)运行中的线程
        6)由引导类加载器加载的对象
        7)GC控制的对象

分析完成后,LeakCanary 会在通知栏弹出一条通知,提示开发者检测到了内存泄漏。

提供一个直观的 UI 界面(通常是 LeakActivity),展示泄漏对象的详细信息,包括:

  • 泄漏对象的类型(如 MainActivity)。
  • 泄漏对象的引用链(即哪些对象持有了它的引用)。
  • 可能的泄漏原因分析(如静态变量持有、Handler 未释放等)。

开发者可以通过这个界面快速定位问题,并进行修复。

自制简单Demo实现

按照如上的设计理念,我们也可以自己尝试实现一个简单的Activity泄露检测工具。以下是一个简单的例子,实现了生命周期监听,循环检查走了 onDestroy 回调的 Activity 是否被及时回收。

object LeakActivityTest {

    private val weakReferenceMap = mutableMapOf<String, WeakReference<Activity>>()

    private val supervisedCoroutine =
        CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, throwable ->
            infoLog("CoroutineExceptionHandler: ${throwable.message}")
        })

    private lateinit var loopCheckJob: Job

    private val activityLifecycleCallbacks = object : ActivityLifecycleCallbacks {
        override fun onActivityCreated(p0: Activity, p1: Bundle?) {
        }

        override fun onActivityStarted(p0: Activity) {
        }

        override fun onActivityResumed(p0: Activity) {
        }

        override fun onActivityPaused(p0: Activity) {
        }

        override fun onActivityStopped(p0: Activity) {
        }

        override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {
        }

        override fun onActivityDestroyed(p0: Activity) {
            infoLog("==========>onActivityDestroyed<==========")
            infoLog("activity: ${p0::class.java.simpleName}")
            weakReferenceMap[p0::class.java.simpleName] = WeakReference(p0)
            System.gc()
        }

    }

    /**
     * 注册 ActivityLifecycleCallbacks
     */
    fun registerActivityLifecycleCallbacks(application: Application) {
        application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
    }

    /**
     * 循环检查弱引用是否被回收
     */
    fun startLoopCheckLeak() {
        loopCheckJob = supervisedCoroutine.launch {
            while (true) {
                Thread.sleep(1000)
                // print size
                infoLog("weakReferenceMap size: ${weakReferenceMap.size}")
                weakReferenceMap.forEach {
                    if (it.value.get() == null) {
                        infoLog("activity: ${it.key} has been destroyed")
                    } else {
                        infoLog("activity: ${it.key} is still alive")
                    }
                }
            }
        }
    }

    fun release() {
        loopCheckJob.cancel()
    }
}

LeakCanary 的优点

LeakCanary 是一款非常优秀的内存泄漏检测工具。

具体来具有以下优点:

  • 自动化程度高:无需手动触发,自动检测内存泄漏,适合开发和测试阶段使用。
  • 直观易用:提供清晰的 UI 界面展示泄漏信息,帮助开发者快速定位问题。
  • 轻量级:对应用性能影响较小,不会显著增加应用的体积或运行时开销。
  • 开源免费:由 Square 公司维护,代码开源,社区活跃,易于集成和定制。

LeakCanary 的局限性

尽管 LeakCanary 是一款非常优秀的内存泄漏检测工具,但它也有一些局限性:

  • 仅针对 Activity 和 Fragment:默认情况下,LeakCanary 主要检测 Activity 和 Fragment 的泄漏,其他对象(如自定义 View、Service 等)需要手动扩展。
  • Heap Dump 分析耗时:生成和分析 Heap Dump 可能会消耗一定的时间和内存资源,尤其是在内存较大的应用中。
  • 无法实时监控:LeakCanary 是在对象销毁后检测泄漏,无法实时监控内存的使用情况(如内存增长趋势)。
  • 对 ProGuard/R8 混淆支持有限:如果应用启用了代码混淆,泄漏引用链中的类名和方法名可能会被混淆,增加分析难度(但 LeakCanary 提供了一定的反混淆支持)。

【Compose】绘制流程

【Compose】绘制流程

本文介绍了Jetpack Compose的渲染流程

任何一种UI框架,应该都会维护一个需要绘制的节点树,在View中也会有一个View控件树的存在。

Slot Table结构

Compose Runtime 采用了一种特殊的数据结构,称为 Slot Table

Slot Table 与常用于文本编辑器的另一数据结构 Gap Buffer 相似,这是一个在连续空间中存储数据的类型, 底层采用数组实现 。区别于数组常用方式的是,它的剩余空间,称为 Gap,可根据需要移动到 Slot Table 中的任一区域,这让它在 数据插入与删除时更高效

以数据删除为例,如下图:

blogs_compose_slot_table

绘制阶段

Compose要显示界面,也有三个阶段:

  1. 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  2. 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。对于布局树中的每个节点,布局元素都会根据 2D 坐标来测量并放置自己及其所有子元素。
  3. 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

这些阶段通常会以相同的顺序执行,让数据能够 沿一个方向(从组合到布局,再到绘制)生成帧(也称为单向数据流) 。BoxWithConstraints 以及 LazyColumn 和 LazyRow 是值得注意的特例,其子级的组合取决于父级的布局阶段。

从概念上讲,每个帧都会经历这 3 个阶段;

但为了优化性能,Compose 会避免在所有这些阶段中重复执行根据相同输入计算出相同结果的工作。如果可以重复使用前面计算出的结果,Compose 会跳过对应的可组合函数;如果没有必要,Compose 界面不会对整个树进行重新布局或重新绘制。

Compose 只会执行更新界面所需的最低限度的工作。

之所以能够实现这种优化,是因为 Compose 会跟踪不同阶段中的状态读取。

组合

这一步是将各个LayoutNode上树的过程。

代码中的每个可组合函数都会映射到界面树中的单个布局节点。在更复杂的示例中,可组合项可以包含逻辑和控制流,并根据不同的状态生成不同的树。

blogs_compose_compose

布局

在布局阶段,Compose 会使用组合阶段生成的界面树作为输入。

在布局阶段,系统会使用以下三步算法遍历树:

  1. 测量子项:节点会测量其子项(如果有)。
  2. 确定自己的尺寸:节点根据这些测量结果确定自己的尺寸。
  3. 放置子项:每个子节点都相对于节点自身的位置进行放置。

在此阶段结束时,每个布局节点都具有:

  • 分配的宽度和高度
  • 应绘制该图形的 x、y 坐标

blogs_compose_compose

以上面的节点树为例,算法的工作原理如下:

  1. Row 会测量其子项 Image 和 Column。
  2. 系统会测量 Image。它没有任何子节点,因此它会自行确定自己的尺寸,并将尺寸报告回 Row。
  3. 接下来,系统会测量 Column。它会先测量自己的子项(两个 Text 可组合项)。
  4. 系统会测量第一个 Text。它没有任何子项,因此它会自行确定自己的尺寸,并将其尺寸报告回 Column。
  5. 测量第二个 Text。它没有任何子节点,因此它会自行确定自己的尺寸,并将其报告回 Column。
  6. Column 使用子测量结果来确定自己的大小。它使用子项的最大宽度和子项高度的总和。
  7. Column 会相对于自身放置其子项,将它们垂直放置在彼此下方。
  8. Row 使用子测量结果来确定自己的大小。它使用子项的最大高度和子项宽度的总和。然后放置其子项。

请注意,每个节点都只被访问了一次。Compose 运行时只需对界面树进行一次遍历即可测量和放置所有节点,从而提高性能。

当树中的节点数量增加时,遍历树所花费的时间会以线性方式增加。

相反,类比View的架构,如果每个节点被访问多次,则遍历时间会呈指数级增加。这就是为什么在View里面写嵌套结构,会大大影响界面的绘制速度。

绘制

使用上例,树内容会按如下方式绘制:

  1. Row 会绘制它可能具有的任何内容,例如背景颜色。
  2. Image 会自行绘制。
  3. Column 会自行绘制。
  4. 第一个和第二个 Text 分别绘制自身。

Compose 在 Android 上的实现最终依赖于 AndroidComposeView,且这是一个 ViewGroup ,那么按原生视图渲染的角度,看一下 AndroidComposeView 对 onDraw() 与 dispatchDraw() 的实现,即可看到 Compose 渲染的原理。

internal class AndroidComposeView(context: Context) :
    ViewGroup(context), Owner, ViewRootForTest, PositionCalculator {
    
    ...
    
    override fun onDraw(canvas: android.graphics.Canvas) {
    }
      
    ...
    
    override fun dispatchDraw(canvas: android.graphics.Canvas) {
        ...
        measureAndLayout()

        // we don't have to observe here because the root has a layer modifier
        // that will observe all children. The AndroidComposeView has only the
        // root, so it doesn't have to invalidate itself based on model changes.
        canvasHolder.drawInto(canvas) { root.draw(this) }

        ...
    }
    
    ...
}

CanvasHolder.drawInto() 将 android.graphics.Canvas 转化为 androidx.compose.ui.graphics.Canvas 实现传递至顶层 LayoutNode 对象 root 的 LayoutNode.draw() 函数中,实现视图树的渲染。

每个阶段的状态读取影响

组合

@Composable 函数或 lambda 代码块中的 状态读取会影响组合阶段,并且可能会影响后续阶段

当状态值发生更改时,Recomposer 会安排重新运行所有要读取相应状态值的可组合函数。

如果输入未更改,运行时可能会决定跳过部分或全部可组合函数。如需了解详情,请参阅如果输入未更改,则跳过。

根据组合结果,Compose 界面会运行布局和绘制阶段。如果内容保持不变,并且大小和布局也未更改,界面可能会跳过这些阶段。

布局

布局阶段包含两个步骤:测量和放置。

测量步骤会运行传递给 Layout 可组合项的测量 lambda、LayoutModifier 接口的 MeasureScope.measure 方法,等等。

放置步骤会运行 layout 函数的放置位置块、Modifier.offset { … } 的 lambda 块,等等。

每个步骤的状态读取都 会影响布局阶段,并且可能会影响绘制阶段 。当状态值发生更改时,Compose 界面会安排布局阶段。如果 大小或位置发生更改,界面还会运行绘制阶段

更确切地说,测量步骤和放置步骤分别具有单独的重启作用域,这意味着,放置步骤中的状态读取不会在此之前重新调用测量步骤。不过,这两个步骤通常是交织在一起的,因此在放置步骤中读取的状态可能会影响属于测量步骤的其他重启作用域。

绘制

绘制代码期间的状态读取会影响绘制阶段。

常见示例包括 Canvas()、Modifier.drawBehind 和 Modifier.drawWithContent。当状态值发生更改时,Compose 界面只会运行绘制阶段。

【Compose】导航组件navigation使用

【Compose】导航组件navigation使用

本文介绍了Jetpack Compose里页面跳转的navigation组件的使用

Compose导航组件是Jetpack Compose中的一个重要组件,用于管理应用程序中的页面导航流程。它提供了一种简单而灵活的方式来管理不同的屏幕和页面之间的导航。

之前的View架构一般是单Activity,多个Fragment,或者多Activity模式。Compose则是多Activity,多个Composable。

页面跳转单方式有很多,官方推荐的是使用Navigation组件。

依赖配置

主要有三个地方:

  • 首先是navigation-compose组件的依赖配置。
  • 界面在导航时有传参数的需求的话,需要使用kotlin的序列化注解来标注数据类或者单例类,需要配置kotlin的序列化插件。
  • 最后是序列化的依赖配置。
[versions]
kotlin = "2.1.0"
navigation = "2.8.5"
serialization = "1.7.3"

[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }

serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "serialization"}

[plugins]
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

包含当前导航目的地的界面元素。也就是说,当用户浏览应用时,该应用实际上会在导航宿主中切换目的地。

一种数据结构,用于定义应用中的所有导航目的地以及它们如何连接在一起。

用于管理目的地之间导航的中央协调器。该控制器提供了一些方法,可在目的地之间导航、处理深层链接、管理返回堆栈等。

类比我们开车的场景,NavHost就是车,NavGraph就是路,NavController就是司机。

首先在起始地点,然后确定路线,然后司机控制车去往目的地。

使用

第一步,起始地点,在应用中,就是应用的首页,开屏进入之后的第一个页面。随便取一个HomePage

@Composable
fun HomePage() {
    Column {
        Text(text = "Home Page")
        Button(onClick = { /*TODO*/ }) {
            Text(text = "Go to Detail")
        }
    }
}

第二步,确定路线,也就是NavGraph,定义导航图。这里需要先定义好需要跳转的页面。


@Composable
fun HomePage(homeDate: HomeData, homeToAbout: () -> Unit) {
    Box(modifier = Modifier.fillMaxSize(1f), contentAlignment = Alignment.Center) {
        Column {
            Text(text = "HomePage data: ${homeDate.name}")
            Button(onClick = homeToAbout) {
                Text(text = "HomeToAbout")
            }
        }
    }
}

@Composable
fun AboutPage(backStack: () -> Unit) {
    Box(
        modifier = Modifier.fillMaxSize(1f),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "AboutPage")
        Button(onClick = backStack) {
            Text(text = "Goto HomePage")
        }
    }
}

导航过程中传参数和页面标记,我们定义两个数据类来标记:

@Serializable
data class HomeData(val name: String)

@Serializable
object About

创建导航图:

val navController = rememberNavController()
val graph = remember {
    navController.createGraph(startDestination = HomeData("initial data")) {
        composable<HomeData> { navBackStackEntry ->
            val homeData = navBackStackEntry.toRoute<HomeData>()
            HomePage(homeDate = homeData) {
                navController.navigate(About)
            }
        }
        composable<About> {
            AboutPage {
                navController.navigate(HomeData("about page to home page"))
            }
        }
    }
}
NavHost(navController = navController, graph = graph)

使用时,更简化的写法可以像下面这样。

直接将NavGraph的第二个参数放在末尾,NavHost后面写成lambda的形式。

NavHost(navController = navController, startDestination = ScreenTitle.Home.name) {
    composable(route = ScreenTitle.Home.name) {
        HomeScreen(
            weatherScreenState,
            onNavToAbout = { navController.navigate(ScreenTitle.About.name) },
            onNavToAuthor = { navController.navigate(ScreenTitle.Author.name) })
    }
    composable(route = ScreenTitle.About.name) {
        AboutScreen(onBack = { navController.popBackStack() })
    }
    composable(route = ScreenTitle.Author.name) {
        AuthorScreen(onBack = { navController.popBackStack() })
    }
}

为了统一管理提高可扩展性,我们可以使用一个密封类来管理所有的页面的导航路由数据。

@Serializable
sealed class Screen(val route: String) {
    @Serializable
    object MainPage : Screen("mainPage")

    @Serializable
    object ArticlePage : Screen("articlePage")

    @Serializable
    object PicturePage : Screen("picturePage")

    @Serializable
    object ElsePage : Screen("elsePage")
}

【Compose】重组及重组范围优化

【Compose】重组及重组范围优化

本文介绍了Compose的重组流程,主要是最小重组范围的界定和优化

更新UI流程对比

View架构

在原生的View,命令式架构中,如果要使用新的数据,来刷新更改某个控件的显示状态,可以调用这个控件类的状态set方法,例如将某个TextView的文本内容进行修改:

binding.tvTest.text = "test a very very very very very very long text"

TextView的setText方法会触发重新绘制,但是如果这个TextView的父控件的宽高没有发生变化,那么就不会触发重新绘制。如果这个父控件的宽高发生了变化,那么就会触发重新绘制。并且所有受影响的View和ViewGroup控件均会更新。

Compose架构

在 Compose 架构中,您只需要更新这个新的可观察状态的数据,然后就可以自动地重新调用一次可组合函数。这样做会导致函数进行重组。Compose 框架可以智能地仅重组已更改的组件。大致的更新流程上我认为是相同的,尽量只更新受影响的Composeable函数。

这里感受感受写法的差异。

例如,假设有以下可组合函数,用于显示一个按钮,并记录点击的次数:

@Composable
fun TestDemo(){
    var clickTimes by remember { mutableStateOf(0) }

    ClickCounter(clickTimes){
        clickTimes++
    }
}

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

每次点击该按钮时,调用方都会在lambda里更新 clicks 的值。Compose 会再次调用 lambda 与 Text 函数以显示新值;此过程称为“重组”。不依赖于该值的其他函数不会进行重组。

Compose 编译器在背后做了大量工作来保证 recomposition 范围尽可能小,从而避免了无效开销。

重组作用域

还是以这个流程举例

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var clickTimes by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")
    Button(onClick = { clickTimes += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Text("I've been clicked $clickTimes times").also {
            Log.d (TAG, "Text recomposition")
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

当按钮点击之后,只有Text和Button内容这个lambda会进行重组。外部的TestDemo和这个Button组件不会进行重组。

为什么不是只有Text进行重组呢?

因为Android系统基于C++编译的虚拟机,在调用到clickTimes变化之后,实际会走两个步骤,将这个新的clickTimes拼接成一个新字符串:

I've been clicked $clickTimes times

然后将这个新的字符串赋值给Text的text属性,这个过程是在Button的lambda中完成的,所以需要将Button Content这个lambda也进行重组。

将委托改为等于

val clickTimes = remember { mutableStateOf(0) }

这种写法会导致Button和外部的TestDemo重组吗?

依然不会。

  • 第一,Compose 关心的是代码块中是否有对 state 的 read,而不是 write。

  • 第二,这里的 = 并不意味着 text 会被赋值新的对象,因为 text 指向的 MutableState 实例是永远不会变的,变的只是内部的 value

将字符串提取到外面

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var clickTimes by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")

    val stringTest = "I've been clicked $clickTimes times"

    Button(onClick = { clickTimes += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Text(stringTest).also {
            Log.d (TAG, "Text recomposition")
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

这种写法会导致Button和外部的TestDemo重组吗?

答案是上面所有的打印log的地方都会参与重组,因为stringTest是一个变量,外部的TestDemo和Button对它都有read的可能,这个变量的变化会影响到所有的读取方。

所以,这种变量里直接使用了remember变量的写法是不推荐的。

日志:

TestDemo recomposition
lambda recomposition
Button content recomposition
Text recomposition
Button recomposition

插入 重组顺序

还可以看出重组的顺序是从clickTimes这个变量的最紧密的读取方开始,发散进行的。

  • 首先stringTest是直接使用方,所以拥有这个变量的TestDemo会进行重组。
  • lambda代码块在编译后会编译成静态方法,在TestDemo重组调用后,会立即调用lambda代码块的初始化方法,即对其进行重组。
  • Button内容的lambda重组原理同上
  • Text和Button的重组就是Compose的正常流程,编译之后的调用顺序为从内部到外部调用。

将Text用Box包一层

如果我们再使用Box这个组件包裹一下Text,会有什么效果呢?

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var times by remember { mutableStateOf(0) }
    Log.d (TAG, "TestDemo recomposition")

    val stringTest = "I've been clicked $times times"

    Button(onClick = { times += 1 }.also {
        Log.d (TAG, "lambda recomposition")
    }) {
        Log.d (TAG, "Button content recomposition")
        Box {
            Log.d (TAG, "Box recomposition")
            Text(stringTest).also {
                Log.d (TAG, "Text recomposition")
            }
        }
    }.also {
        Log.d (TAG, "Button recomposition")
    }
}

日志打印:

TestDemo recomposition
lambda recomposition
Button content recomposition
Box recomposition
Text recomposition
Button recomposition

可以看到Box的重组是在Button内容的lambda重组之后进行的。而不是在Text的重组最后面。

这是为什么呢?

因为Box的实现是一个inline方法,编译之后会被铺平到调用的地方,而不是按照像Button和Text的层级结构从内到外。

所以Box这种inlne方法,是不会算作为最小重组范围内的。而是和其调用的组件共享重组优先级。

同样的,Column、Row、Box 乃至 Layout 这种容器类 Composable 都是 inline 函数。

优化重组范围最小化

在上面的例子中,我们可以看到,一些看起来类似的写法,所产生的最小重组范围是不一样的。

那么如何优化代码,使重组范围最小化呢。

我们可以使用一个非inline的Composable函数包裹起来,这样就可以避免Box的这种情况。

val TAG = "TestDemoPage"
@Composable
fun TestDemo() {

    var times by remember { mutableStateOf(0) }
    Log.d(TAG, "TestDemo recomposition")
    
    Button(onClick = { times += 1 }.also {
        Log.d(TAG, "lambda recomposition")
    }) {
        Log.d(TAG, "Button content recomposition")
        Wrraper {
            Text("I've been clicked $times times").also {
                Log.d(TAG, "Text recomposition")
            }
        }
    }.also {
        Log.d(TAG, "Button recomposition")
    }
}

@Composable
fun Wrraper(content: @Composable () -> Unit) {
    Log.d(TAG, "Wrraper recomposition")
    Box {
        Log.d(TAG, "Box recomposition")
        content()
    }
}

这样在点击之后,所需要重组范围的就只有Text一个组件了。

结论

Compose 在编译期分析出会受到某 state 变化影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。在下一渲染帧到来之前 Compose 会触发 recomposition ,并在重组过程中执行 invalid 代码块。Invalid 代码块即编译器找出的下次重组范围。能够被标记为 Invalid 的代码必须是非 inline 且无返回值的 @Composalbe function/lambda,必须遵循 重组范围最小化 原则。

  • 对于 inline 函数,由于在编译期会在调用处中展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围。

  • 而对于有返回值的函数,由于返回值的变化会影响调用方,因此无法单独重组,而必须连同调用方一同参与重组,因此它不能作为入口被标记为 invalid

【Compose】一些有用的Compose开发技巧

【Compose】一些有用的Compose开发技巧

本文记录了若干有用的Compose开发技巧

使用Jetpack Compose有很长一段时间了,最近也有在开发跨平台的版本CMP。结合网络上和实际项目的应用,分享几个可以增强性能,提升可读性和可维护性的Compose开发技巧。

状态提升

这个在很多地方都有提到,可以提升Composable的可测试性和可维护性。

具体来说,就是将Composable的状态提升到父Composable中,由父Composable来管理和维护状态,而子Composable只负责展示和交互。这样可以避免子Composable的状态和逻辑与父Composable的状态和逻辑耦合在一起,从而提高代码的可维护性和可测试性。

例如一个简单的计数组件:

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text("Clicked $count times")
    }
}

MVI架构设计

这个核心理念用一句话概括就是:数据向下流动,事件向上流动

  • ViewModel组件来管理维护数据状态
  • View层的Composable组件来观测数据
  • 操作和输入事件通过回调的方式向上流动,触发ViewModel的状态更新

还是以计数为例:

// ViewModel
class CounterViewModel : ViewModel() {
    private val _count = mutableStateOf(0)
    val count: State<Int> = _count

    fun increment() {
        _count.value++
    }
}

// View
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()
    
    Counter(
      count = count, 
      onIncrement = viewModel::increment
    )
}

插槽化设计

将通用的父组合项抽离出来,将子组合项以插槽的形式传递进去,实现代码的复用和灵活性。

@Composable
fun FancyCard(content: @Composable () -> Unit) {
    Card {
        content()
    }
}

Composed的相当一部分的官方API都是这样设计的。

ViewModel层使用StateFlow作为数据状态

LiveData这个类在Compose中已经不推荐使用了,因为它的设计初衷是为了与传统的Android组件(如Activity和Fragment)进行集成,而Compose是一个基于声明式UI的框架,不依赖于传统的组件生命周期。使用StateFlow可以提供更好的Kotlin适配和灵活性。

class MainViewModel : ViewModel() {
    private val _themeState = MutableStateFlow(ThemeState.DEFAULT)
    val themeStateStateFlow = _themeState.asStateFlow()

    fun setThemeState(themeState: ThemeState) {
        _themeState.value = themeState
    }
}

// View
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val themeState by viewModel.themeStateStateFlow.collectAsState()

    LaunchEffect(themeState) {
        // 处理主题状态变化
    }
}

Composables分类

将各个可组合项分类,如状态相关的、布局相关的、样式相关的等,方便管理和维护。有的只需要显示固定的UI,有的是响应数据变化的组件。

// 状态相关的Composable
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text("Clicked $count times")
    }
}

使用脚手架Scaffold组件

对于移动端来说,界面的布局一般是有固定的部分,如顶部的导航栏、底部的底部栏等。这些部分可以使用Scaffold组件来实现。

@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Compose Scaffold") }
            )
        },
        bottomBar = {
            BottomAppBar {
                Button(onClick = { /* 处理底部按钮点击 */ }) {
                    Text("底部按钮")
                }
            }
        }
    ) { innerPadding ->
        // 主要内容区域
    }
}

对列表类控件,设置元素的key减少重组,配置动效

列表类控件,如LazyColumnLazyRow等,在Compose中使用时,需要为每个列表项设置一个唯一的key,这样可以帮助Compose在列表项发生变化时,只重新组合发生变化的项,而不是全部重新组合。

LazyColumn {
    items(items = items, key = { item -> item.id }) { item ->
        ListItem(item)
    }
}

有了这个key标识之后,Compose列表项还可以对每一个item组件都应用动效,比如一个元素A前面再插入一个元素B,元素A的位置变化就不是闪现,而是平滑的过渡。注意列表原数据中的每一个元素的key都要求唯一,如果出现了重复的key标识,会报运行错误。

DerivedStateOf

derivedStateOf 的核心作用是只在它的计算结果发生变化时才触发重组。

例如:

// 不推荐
val isFormValid = email.isNotEmpty() && password.length >= 8

在上面的代码中,isFormValid 是一个计算属性,它依赖于 emailpassword 两个变量。当这两个变量发生变化时,就算 isFormValid 的值没有变化,也会触发持有这个属性的外部可组合项发生重组。比如在桌面端,一般会有一个Window可组合项,这个可组合项一个周期内只执行一次,如果在这里触发重组会直接报错。

推荐做法:

// 推荐
val isFormValid by derivedStateOf {
    email.isNotEmpty() && password.length >= 8
}

rememberSaveable

rememberSaveable 是 Jetpack Compose 中用于在配置变更(如屏幕旋转)或进程被系统杀死后保留状态的工具。

它和 remember 很像,但功能更强大:

  • remember 只在重组(recomposition)过程中保留状态。如果用户旋转了屏幕,Activity 被重建,remember 保存的状态就会丢失。
  • rememberSaveable 不仅能在重组时保留状态,还能在 Activity 或进程被销毁和重建时(例如,屏幕旋转、从后台长时间返回)保存状态。
val name by rememberSaveable { mutableStateOf("") }

善用LaunchedEffect

Compose中提供了很多Side Effect方法,用来处理一些副作用,如网络请求、文件读写等。

其中LaunchedEffect是最常用的一个,它可以在Composable中启动一个协程,用来处理一些异步操作。

比如界面的初始化数据获取,会使用Unit作为key,这样只会在Composable第一次被调用时执行一次。

LaunchedEffect(Unit) {
    // 初始化数据获取
}

除此之外,LaunchedEffect还可以用来处理一些变化执行的场景

LaunchedEffect(themeState) {
    // 处理主题状态变化
}

自定义Modifier

一些超高频的Modifier可以自定义,比如点击事件、背景设置、间距设置等。

fun Modifier.defaultPadding() = padding(16.dp)
fun Modifier.defaultClickable() = clickable {
    // 处理点击事件
}

【Compose】Intrinsic Measurement

【Compose】Intrinsic Measurement

本文介绍了Jetpack Compose的固有特性测量解决嵌套卡顿的问题的原理

Compose 有一项规则,子项只能测量一次,测量两次就会引发运行时异常

但是,有时需要先收集一些关于子项的信息,然后再测量子项。

借助 Intrinsic Measurement 固有特性,您可以先 查询子项 ,然后再进行实际测量。

对于可组合项,您可以查询其 intrinsicWidth 或 intrinsicHeight:

  • (minmax)IntrinsicWidth:给定此宽度,可以正确绘制内容的最小/最大宽度是多少?
  • (minmax)IntrinsicHeight:给定此高度,可以正确绘制内容的最小/最大高度是多少?

View架构测量对比

有这么一个很常见的场景:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <View
        android:layout_width="match_parent"
        android:layout_height="48dp" />

    <View
        android:layout_width="120dp"
        android:layout_height="48dp" />

    <View
        android:layout_width="160dp"
        android:layout_height="48dp" />
</LinearLayout>

第一个View并没有给定宽度,是对齐父控件。而父控件的宽度又是 wrap_content 的配置。

这时候, LinearLayout 就会先以 0 为强制宽度测量一下这个子 View,并正常地测量剩下的其他子 View,然后再用其他子 View 里最宽的那个的宽度,二次测量这个 match_parent 的子 View,最终得出它的尺寸,并把这个宽度作为自己最终的宽度。

有些场景甚至会有三次及以上的测量。

更甚,如果是嵌套场景,层级每深一级,测量次数就会以指数级增长。

Compose如何规避的

Compose在所有组合项尺寸都明确的情况下,也是不需要进行特殊处理。

在未明确指定尺寸的情况下,Compose会使用一个 固有特性测量 的机制,来规避掉父子组合项做出递归的多次测量。

所谓的 Intrinsic Measurement,指的是 Compose 允许父组件在对子组件进行测量之前, 先测量一下子组件的「固有尺寸」 ,直白地说就是「你内部内容的最大或者最小尺寸是多少」。

这是一种 粗略的测量 ,虽说没有真正的「二次测量」模式那么自由,但功能并不弱,因为各种 Layout 里的重复测量,其实本来就是先进行这种「粗略测量」再进行最终的「正式测量」的——比如刚才说的那种「外面 wrap_content 里面 match_parent」的。

这种「粗略」的测量是很轻的,并不是因为它量得快,而是因为它在机制上不会像传统的二次测量那样,让组件的测量时间随着层级的加深而不断加倍。

当界面需要这种 Intrinsic Measurement——也就是说那个所谓的「固有特性测量」——的时候,Compose 会 先对整个组件树进行一次 Intrinsic 测量 ,然后再对整体进行正式的测量。

举例

@Composable
fun IntrinsicTest() {
    Column(
        modifier = Modifier
            .wrapContentSize()
            .background(Color.Red)
    ) {
        Text(text = "Hello Test!", modifier = Modifier.fillMaxSize(1f))
    }
}

demo0

这里和上面的View的例子是一样的,父组合项的size是wrap的,子组合项的size是对齐上一级的。

这时候运行这个Demo。我们可以看到,整个Column的大小是占满了整个屏幕的,和View架构的表现正好相反。

因为父组合项没有划定尺寸限制,那子组合项就会无限扩张自己的领地,最终对他的测量数据就是占满屏幕的宽高。

使用固有尺寸测量参数

@Composable
fun IntrinsicTest() {
    Column(
        modifier = Modifier
            .height(IntrinsicSize.Min)
            .background(Color.Red)
    ) {
        Text(text = "Hello Test!", modifier = Modifier.fillMaxSize(1f))
    }
}

结果:

demo0

我将外部Column的高度参数设置为 IntrinsicSize.Min 就可以达到要求。

height(IntrinsicSize.Min) 可将其子项的高度强行调整为最小固有高度。由于该修饰符具有递归性,因此它将查询 Column 及其子项 minIntrinsicHeight。 而Text 元素的 minIntrinsicHeight 为 文本的固有宽高。

因此 Column 元素的 height 约束条件将和Text的最小占用的宽高一致。而Text设置fillMaxSize之后获取的高度,就会变成Text占用的最小高度了。

如果将 Min 改成 Max 呢?

那效果也是一致的,如果您查询具有无限 height 的 Text 的 minIntrinsicHeight,它将返回 Text 的 height,就好像该文本是在单行中绘制的一样。

实际使用场景

举例1 分割线自适应高度

要实现下面这个效果,两个文字中间画一条分割线:

blogs_compose_intrinc_demo1

我们该怎么做?我们可以将两个 Text 放在同一 Row,并在其中最大程度地扩展,另外在中间放置一个 Divider。我们需要将 Divider 的高度设置为与最高的 Text 相同,粗细设置为 width = 1.dp。

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )
        HorizontalDivider(
            color = Color.Black,
            modifier = Modifier.fillMaxHeight().width(1.dp)
        )
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

预览时,我们发现 Divider 会扩展到整个屏幕,这并不是我们想要的效果:

blogs_compose_two_text_max

两个文本元素并排显示,中间用分隔线隔开,但分隔线向下延伸到文本底部下方

之所以出现这种情况,是因为 Row 会逐个测量每个子项,并且 Text 的高度不能用于限制 Divider。我们希望 Divider 以一个给定的高度来填充可用空间。为此,我们可以使用 height(IntrinsicSize.Min) 修饰符。

height(IntrinsicSize.Min) 可将其子项的高度强行调整为最小固有高度。由于该修饰符具有递归性,因此它将查询 Row 及其子项 minIntrinsicHeight。

将其应用到代码中,就能达到预期的效果:

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )
        HorizontalDivider(
            color = Color.Black,
            modifier = Modifier.fillMaxHeight().width(1.dp)
        )
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

// @Preview
@Composable
fun TwoTextsPreview() {
    MaterialTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

这时候的结果就是我们需要的了。

举例2 兄弟组合项对齐数据

需求是在屏幕上显示左右两个栏目,两边的内容不一定一样多,但是背景色块需要一样高。

blogs_compose_intrinc_demo2

我们使用row来分栏,然后在每个column里填数据,不主动设置高度。

@Composable
fun IntrinsicTest() {
    val shortList = remember { shortList }
    val longList = remember { longList }
    Row {
        Column(
            modifier = Modifier
                .weight(0.5f)
                .background(Color.Red)
        ) {
            shortList.forEach { Text(text = it) }
        }
        Column(
            modifier = Modifier
                .weight(0.5f)
                .background(Color.Blue)
        ) {
            longList.forEach { Text(text = it) }
        }
    }
}

结果:

blogs_compose_intrinc_demo21

我们发现两个Column的高度是不一致的。

如果我为了使两侧高度显示一致,直接将两边的高度值写死,那么在不同屏幕上的自适应又会出问题。

这时候我们使用 IntrinsicSize.Max 来解决这个问题。设置为max,父组合项的高度会取子项中最大的高度。然后让两个子项的高度直接 fillMaxHeight

@Composable
fun IntrinsicTest() {
    val shortList = remember { shortList }
    val longList = remember { longList }
    Row(modifier = Modifier.height(IntrinsicSize.Max)) {
        Column(
            modifier = Modifier
                .weight(0.5f)
                .fillMaxHeight(1f)
                .background(Color.Red)
        ) {
            shortList.forEach { Text(text = it) }
        }
        Column(
            modifier = Modifier
                .weight(0.5f)
                .fillMaxHeight(1f)

                .background(Color.Blue)
        ) {
            longList.forEach { Text(text = it) }
        }
    }
}

结果:

blogs_compose_intrinc_demo22

可以看到两个column的高度是一样的了。

【Compose】remember关键字

【Compose】remember关键字

本文介绍了Compose中的remember关键字,用法及简要原理

remember 关键字,用于在 Jetpack Compose 中保存可组合函数在重组期间的状态。它通过在组合中缓存计算结果,确保每次重组时状态保持不变。

使用举例:

@Composable
fun RememberTestPage() {
    val rememberCount = remember { mutableIntStateOf(0) }

    Column {
        Text(text = "Remember Count: ${rememberCount.intValue}")
        Button(onClick = { rememberCount.intValue++ }) {
            Text(text = "Increment")
        }
    }
}

这个例子中,使用 remember 关键字来记录点击次数,并在每次点击按钮时更新状态。确保Text可以显示正确的值。

不只是可以记录一个 IntState ,也可以用来记录其他复杂数据,像列表,对象等。

@Composable
fun ListExample() {
    val items = remember { mutableStateListOf("Item 1", "Item 2") }

    Column {
        items.forEach { item ->
            Text(item)
        }
        Button(onClick = { items.add("Item ${items.size + 1}") }) {
            Text("Add Item")
        }
    }
}

对简单数据的记录,我经常使用的是另一种委托的写法,声明为var,这样可以不用使用 value 访问器,直接使用变量名。

@Composable
fun RememberTestPage() {
    var rememberCount by remember { mutableIntStateOf(0) }

    Column {
        Text(text = "Remember Count: $rememberCount")
        Button(onClick = { rememberCount++ }) {
            Text(text = "Increment")
        }
    }
}

运行流程解析

remember是Compose运行时库里的一系列重载的顶层方法。

值存储在哪里

remember 存储的值位于 Compose 运行时 (Compose Runtime)组合 (Composition) 内部。

具体来说 remember 计算的值会在 初始组合 期间存储在 Compose 运行时维护的内存结构中,这个结构就是“组合”。

当可组合函数(Composable)因为状态变化而 重组 时,remember 会返回上次存储的值,而不是重新执行其 Lambda 表达式内的计算(除非它的 key 参数发生了变化)。

remember 所存储的值的生命周期与调用它的可组合项的生命周期绑定。只要该可组合项仍在“组合”中,值就会被保留。一旦该可组合项从组合中移除(例如,不再显示或使用 if 条件被移除),这个被记住的值就会被遗忘

如果需要状态在 配置更改 (例如屏幕旋转)或 进程被系统终止 后仍然保留,应该使用 rememberSaveablerememberSaveable 内部会将值序列化并存储在 Android 的 Bundle 机制中(类似于 onSaveInstanceState()),从而实现跨配置更改和进程终止的持久化。

调用链分析

对于上面那个例子,对应下面这个重载方法。

@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

calculation 这个 lambda 表达式,是每个重载方法的共同的参数,当 缓存无效或缓存不存在时 ,calculation 会被执行,并且其返回值会被存储在缓存中供后续使用。

将其调用链简化如下:

@Composable
fun <T> remember(vararg inputs: Any?, calculation: @DisallowComposableCalls () -> T): T {
    val composer = currentComposer
    return composer.cache(false) {
        calculation()
    }
}

首先获取当前的 Composer 对象,即当前正在进行重组的实例对象。在 Composer 中,有一个cache方法,专门供 remember 调用,用于缓存计算结果。

@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

获取缓存时的这个 invalid 参数用于控制缓存是否失效。当 invalid 为true时,缓存会被视为无效,并且会重新计算。

具体的,当 remember 传输多个参数时,这个 invalid 的值就是这些传来的参数变化与否,当它们发生变化时, invalid 就是true,缓存就会失效,并重新计算替换旧值。

例如:

@Composable
fun RememberTestPage(intValue: Int) {
    var rememberCount = remember(intValue) { intValue + 2 }
}

【Compose】Compose编译到显示的流程解析

【Compose】Compose编译到显示的流程解析

本文介绍了Compose声明式框架从可组合项方法的编写到最终屏显的流程解析

Jetpack Compose 是 Google 推出的用于构建原生 Android UI 的现代声明式框架,它简化了 Android 应用的 UI 开发过程。

将XMl+Java的开发方式转变为Kotlin语法的Compose的开发方式,让开发者可以使用更简洁、更直观的代码来构建用户界面,开发体验上极致的统一。

那么一个 @Composable 方法是如何变成屏幕上的显示内容的呢?下面从Android平台为切入点,从编译阶段到运行时阶段,详细解析 Compose 的显示流程。再看看Compose Multiplatform这个跨平台框架和 Android 平台上的原型Jetpack Compose有何异同。

回顾View架构

我们的应用要加载一个显示界面时,会经历以下几个阶段。首先将xml的布局文件,按照内部的父控件子控件的包含关系,将它们解析成View树,然后将View树交给WindowManager进行显示。

具体的:

1. xml文件解析构建View树

当调用 setContentView(R.layout.xxx)LayoutInflater.inflate() 时,系统会通过 LayoutInflater 解析 XML 文件。

使用 XmlPullParser 逐行解析 XML 标签,转换为内存中的视图对象(如 TextView、LinearLayout 等)。

根据标签属性(如 android:text、android:layout_width)设置视图的初始参数。

解析后的 XML 会生成一个对应的 视图树(View Hierarchy),根节点是顶层布局(如 ConstraintLayout),子节点是嵌套的视图。

每个视图的构造函数会被调用,并通过 AttributeSet 读取 XML 中的属性值(如 textSize、background)。

2. 测量(Measure)

onMeasure(int widthMeasureSpec, int heightMeasureSpec) ,父视图通过 MeasureSpec 向子视图传递尺寸约束(如 match_parent、wrap_content 或固定值)。

视图根据约束计算自身尺寸(可能需要多次测量,尤其是嵌套布局)。

最终通过 setMeasuredDimension() 保存测量结果。

3. 布局(Layout)

onLayout(boolean changed, int l, int t, int r, int b) ,父视图根据测量结果确定子视图的位置(左上右下坐标)。

例如:LinearLayout 会按垂直/水平方向依次排列子视图。

4. 绘制(Draw)

onDraw(Canvas canvas) ,视图通过 Canvas 和 Paint 绘制自身内容(如文本、背景、边框)。

绘制顺序:背景 → 主体内容(如文本/图片) → 子视图 → 前景(如滚动条)。

支持硬件加速时,绘制指令会转为 RenderNode 并交由 GPU 处理。

5. 合成送显

SurfaceFlinger 合成。各层的绘制结果(Surface)由 SurfaceFlinger 合成为最终帧。提交到屏幕时,通过 VSync 信号同步,将帧数据发送到屏幕缓冲区显示。

一般View架构的应用,在做布局相关性能优化时,有如下手段:

减少布局层级:避免嵌套过深,用 ConstraintLayout 替代多层 LinearLayout。

避免过度绘制:通过 onDraw() 优化或设置 android:background=null。

使用 ViewStub:延迟加载复杂但非立即显示的布局。

Compose 的 UI Tree

前言一 Gap Buffer

Gap Buffer 是一种用于优化局部更新的高效数据结构。其核心思想是通过维护一个 可移动的“间隙” 来优化局部性操作,在数组中预留一个空白区域(Gap)来实现高效的插入和删除操作,可以减少内存移动的开销。

最常见的应用——文本编辑器

​缓冲区结构​​,将内存分为三部分:左文本区、间隙(Gap)和右文本区。 初始时,间隙通常位于缓冲区末尾(如 [文本][间隙] ),但随着输入的光标移动,这个间隙会动态调整位置。

  • ​当在光标处插入字符时,直接将数据填入间隙。若间隙不足,则扩展间隙(如重新分配更大的内存)。
  • 删除字符时,通过调整间隙边界“吸收”被删除的字符,避免立即移动数据。
  • ​​光标移动​​:移动光标时,间隙会同步移动到新位置,此时需要将原间隙两侧的文本交换位置(例如,光标右移时,将右文本区的左端字符移到左文本区末尾)。

关键操作示例​​:

​​插入字符​​:
假设缓冲区状态:[Hello][ ][World]([]表示间隙)。
在Hello后插入! → [Hello!][ ][World],间隙缩小。

​​移动光标​​:
光标从Hello!后移动到W前:
原状态:[Hello!][ ][World]
移动后:[Hello! ][W][orld](间隙移动到W前,W从右文本区移到左文本区)。

​​删除字符​​:
删除W → [Hello! ][][orld],间隙扩展“吸收”W。

其优势主要为高效的局部操作,劣势为大范围的操作时,需要移动大量数据调整间隙的位置,最坏的情况下可能需要O(n)的时间复杂度。同时间隙填满后,扩展的成本也较高。

前言二 Slot Table

数据结构描述

SlotTable 是 Compose 里的内部数据结构,用于跟踪 组合层次结构 中的视图数据,包括 节点、组、键和记忆值 。这个数据结构上的各个组的结构及其值由编译器决定,并在运行时随着层次结构和应用状态的建立和更新而变化。

SlotTable 是一个 树形结构 ,其中每个节点都是一个组,且内部可能有子项。其每个元素被称作“插槽(Slot)”。每个插槽能存储特定类型的数据,像组件的类型、属性、状态等信息。它以扁平化的方式存储 UI 树的信息,取代了传统的树形结构,从而简化了 UI 的管理与更新操作。

组(Groups)包含以下信息:

  1. : 用于区分组的识别符,通过快速识别组的变化来帮助重新组合。它不需要是唯一的。
  2. 标志: 有关分组的元数据,包括分组所含节点数的计数器。
  3. : 为组存储的值的有序列表,可以修改或删除。槽支持引用类型和基元,可独立跟踪以避免自动排序惩罚。实用槽由槽表管理,其他槽则由金豪编译器生成,跟踪记忆值和可编译函数参数。

还有以下的可选属性:

  1. 节点:与组相关联的节点,由 Applier 使用。Composer 通过 SlotTable 在内部维护这些节点。
  2. 对象键:补充标准整数键的可选键。
  3. 辅助值*:与节点相关联的辅助数据值,设置与组的其他槽无关。它用于记录 CompositionLocal 地图。

SlotTable 的实现是一个基于页面的链接表,它将组信息编码成整数,并将其打包成数组,以避免额外的分配。组内部维护了 几个指针 指向其父组、第一个子组和下一个同级组的指针,编码为指向页面的地址和页面内的索引。

该数据结构返回和使用的所有 GroupAddresses 都是稳定的。一旦分配,地址将不会改变,除非将组删除并重新添加到表中。

一个 SlotTable 可以与另一个 SlotTable 共享地址空间,这样就可以通过指针重新分配而不是内存复制,在表之间有效地移动组。

编译器对 Composable 函数的转换

我们编写界面UI时,使用的可组合项都会添加一个 @Composable 注解,被 @Composable 所注解的函数称为 可组合函数

添加该注解的函数会被真实地改变类型,改变方式与 suspend 类似,在编译期进行处理,只不过 Compose 并非语言特性,无法采用语言关键字的形式进行实现。

示例:

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

// 编译器生成的近似结构(概念性表示)
fun Greeting(name: String, parentComposer: Composer, changed: Int) {
    val composer = parentComposer.startRestartGroup(GROUP_HASH)

    val dirty = calculateState(changed)
    
    if (stateHasChanged(dirty) || composer.skipping) {
        Text("Hello $name", composer = composer, changed = ...)
    } else {
        composer.skipToGroupEnd()
    }

    composer.endRestartGroup()?.updateScope {
        Greeting(name, changed)
    }
}

可见被 @Composable 注解后,函数增添了额外的参数,其中的 Composer 类型参数 作为运行环境 贯穿在整个可组合函数调用链中,所以可组合函数无法在普通函数中调用,因为 不包含相应的环境

可组合函数实现的起始与结尾通过 Composer.startRestartGroup()Composer.endRestartGroup() 在 Slot Table 中创建 Group,而可组合函数内部所调用的可组合函数在两个调用之间创建新的 Group,从而 在 Slot Table 内部完成视图树的构建

Composer 根据当前是否正在修改视图树而确定这些调用的实现类型。

在视图树构建完成后,若数据更新导致部分视图需要刷新,此时非刷新部分对应可组合函数的调用就不再是进行视图树的构建,而是视图树的访问,正如代码中的 Composer.skipToGroupEnd() 调用,表示在访问过程中直接跳到当前 Group 的末端。

Composer 对 Slot Table 的操作是读写分离的,只有写操作完成后才将所有写入内容更新到 Slot Table 中。

除此之外,可组合函数还将通过 传入标记参数的位运算 判断内部的可组合函数执行或跳过,这可以 避免访问无需更新的节点 ,提升执行效率。

Gap Buffer在 Compose 中的应用

Gap Buffer 是 Compose 内部用于管理 Slot Table 的核心数据结构。

Compose 编译器会将 @Composable 函数编译成上面的形式。当Compose 运行时执行这些函数,并将组合项的信息添加到一个名为 Slot Table 的数据结构中。Slot Table 的每个槽可以存储关于 Composable 的信息,如其参数、内部状态(如 remember 持有的值)以及其他组合细节。它本质上是记录组合过程的。

Gap BufferSlot Table 的底层实现。在 Slot Table 的上下文中,当 UI 由于状态变化而需要更新时(重组),Compose 需要更新 Slot Table 中的特定部分。由于移动 gap 本身是一个 O(n) 操作,这在典型的 UI 更改中并不频繁。大多数 UI 更新涉及的为小的、局部化的修改,Gap Buffer 在这些场景中非常高效。

Compose 不直接构建传统的 “view tree” 像 Android Views 那样,但它确实在 Composition 阶段 构建了一个 UI tree (node tree) 。这个 UI tree 代表了 UI 元素的层次结构。Slot Table会使用 Gap Buffer 来存储与这个 UI tree 相关的元数据和状态信息。当状态改变时,Compose 会确定哪些 @Composable 函数需要重组。即基于 Slot Table 中的信息来确定哪些部分的 UI tree 需要更新。由于 gap buffer 的高效性,Compose 可以在 Slot Table 中高效地插入、删除或移动 “组” 中的 composables 可组合项,而不需要重建整个 UI tree。这就是 Compose 实现 “smart recomposition” 的关键所在,即只更新受状态变化影响的部分,从而显著提高性能。

整体结构的工作流程如下:

1. 组合阶段 在首次运行或状态改变时,Composable 函数会被执行,生成 UI 描述树。此时,Composer 会遍历这个 UI 描述树,把相关信息写入 Slot Table。例如,可组合函数实现的起始与结尾通过 Composer.startRestartGroup() 与 Composer.endRestartGroup() 在 Slot Table 中创建 Group,以此来表示 UI 树的层次结构。

2. 差异比较阶段 当可组合项所观测的 mutableStateOf 值发生变化,导致部分组合无效。 Compose 重新执行受到影响的 @Composable 函数。即触发了重组,Composer 会将新生成的 UI 描述树与 Slot Table 里存储的上一次组合结果进行比较,找出需要更新的部分,生成一个变更列表(Change List)。

3. 更新阶段 依据变更列表,Composer 对 Slot Table 进行更新,仅修改那些发生变化的插槽,而不改变未变化的部分。这可能涉及插入新的槽来表示新的 composables,删除槽来表示移除的 composables,或者更新现有槽的数据以反映参数/状态的变化。这种局部更新的方式提升了 UI 更新的效率。

Compose的测量过程

Android View 系统,内部可能会进行多次测量,这样测量次数随嵌套层数加深会成几何式增长。而 Compose 采用了 单遍测量(Single-pass Measurement) 的机制,这大大提升了性能和可预测性。

Compose 的测量流程是自上而下、递归进行的,遵循以下三步算法:

  1. 测量子项 (Measure children)
    • 父节点(Composable)会向其所有子节点发出测量请求。
    • 这个请求会向下传递约束条件 (Constraints)。约束条件定义了子项可用的最小和最大宽度、高度。
    • 子项在这些约束条件下决定自己的尺寸。
  2. 确定自身尺寸 (Decide own size)
    • 在子项完成测量并报告其尺寸后,父节点会根据其所有子项的测量结果和自身的逻辑(例如 Row 会累加子项的宽度,Column 会累加子项的高度)来决定自己的最终尺寸。

约束条件 (Constraints) 的传递

在测量过程中,约束条件从父节点向下传递给子节点:

  • 父节点决定了子节点的最大可用空间。它会根据自身被赋予的约束条件和其布局逻辑,生成并传递新的约束条件给子节点。
  • 子节点在这些约束范围内,根据自身内容(如文本长度、图片大小等)和修饰符(Modifiers)的设置,决定自己的尺寸。
  • 叶子节点(没有子节点的 Composable,如 TextImage)直接根据收到的约束条件和自身内容来决定尺寸并报告给其父节点。

测量示例 (以 Row 包含 ImageColumn 为例)

假设我们有一个 UI 结构:

Row {
    Image(...)
    Column {
        Text("Hello")
        Text("Compose")
    }
}

测量流程如下:

  1. Row 节点被要求测量自身。
  2. Row 首先会要求其子项 ImageColumn 进行测量。
  3. 测量 Image
    • Image 是一个叶子节点,它没有子节点。
    • 它根据收到的约束条件和图片自身的尺寸来决定其最终尺寸,并报告给 Row
  4. 测量 Column
    • Column 会先要求其子项(两个 Text Composable)进行测量。
    • 测量第一个 Text 它是叶子节点,根据自身文本内容和约束条件决定尺寸,并报告给 Column
    • 测量第二个 Text 同上,决定尺寸并报告给 Column
    • Column 收到两个 Text 的尺寸后,根据其布局逻辑(通常是最大子项宽度和子项高度之和)来决定自己的尺寸,并报告给 Row
  5. Row 确定自身尺寸和放置子项:
    • Row 收到 ImageColumn 的尺寸后,根据其布局逻辑(通常是子项宽度之和和最大子项高度)来决定自己的尺寸。
    • 最后,Row 会相对于自身的位置来放置 ImageColumn

固有特性测量 (Intrinsic Measurements)

虽然 Compose 强制单遍测量,但在某些情况下,父节点可能需要在 实际测量子节点之前 ,了解子节点的一些 “固有”尺寸信息 (例如,一个 Text 在无限宽度下能达到的最小高度)。这时就用到了固有特性测量 (Intrinsic Measurements)

固有特性测量允许父节点“查询”子节点,获取其在给定约束条件下的最小或最大固有尺寸(如 minIntrinsicWidthmaxIntrinsicWidthminIntrinsicHeightmaxIntrinsicHeight)。这些查询并是真正的测量,它们不会导致子节点被实际测量两次。它们只是让父节点能够根据这些预估信息,来更好地计算在实际测量时应该传递给子节点的约束条件。

例如,当你使用 Modifier.height(IntrinsicSize.Min) 时,它会要求父级布局根据其子项的最小固有高度来确定自身高度。

Android平台的Compose显示

在 Android 平台,Compose UI 实际上运行在一个 ComposeView 内部, ComposeView 是一个特殊的 View 类(它继承自 androidx.compose.ui.platform.AbstractComposeView,而 AbstractComposeView 又是一个 ViewGroup),它充当了 Compose UI 内容的容器。它本身是一个标准的 Android View,可以像其他任何 TextView 或 LinearLayout 一样被添加到 View 层次结构中。

渲染

Compose 在 Android 上的实现最终依赖于 AndroidComposeView,且这是一个 ViewGroup,那么按原生视图渲染的角度,看一下 AndroidComposeView 对 onDraw() 与 dispatchDraw() 的实现,即可看到 Compose 渲染的原理。

@SuppressLint("ViewConstructor", "VisibleForTests")
@OptIn(ExperimentalComposeUiApi::class)
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
internal class AndroidComposeView(context: Context) :
    ViewGroup(context), Owner, ViewRootForTest, PositionCalculator {
    
    ...
    
    override fun onDraw(canvas: android.graphics.Canvas) {
    }
      
    ...
    
    override fun dispatchDraw(canvas: android.graphics.Canvas) {
        ...
        measureAndLayout()

        // we don't have to observe here because the root has a layer modifier
        // that will observe all children. The AndroidComposeView has only the
        // root, so it doesn't have to invalidate itself based on model changes.
        canvasHolder.drawInto(canvas) { root.draw(this) }

        ...
    }
    
    ...
}

可以看到,在 dispatchDraw() 中,调用了 measureAndLayout() 方法,这个方法会执行 Compose 中的测量和布局过程,最终会调用 root.draw() 方法,这个方法会执行 Compose 中的绘制过程,最终将绘制结果绘制到 Canvas 上。

Compose和View兼容使用

ComposeView的使用

在原来的Android项目中使用Compose,可以使用 ComposeView 来嵌入Compose UI。

ComposeView 的工作原理:

  • 当 ComposeView 被添加到 View 层次结构并依附到窗口时,它会启动一个 Compose 组合(Composition)。这个组合会运行你传递给 setContent 的 @Composable 函数。
  • Compose UI 的整个生命周期(组合、布局、绘制)都发生在 ComposeView 的边界内。
  • ComposeView 负责将其内部 Compose UI 的绘制结果,最终通过标准的 Android 渲染管道(HWUI 和 Skia)呈现在屏幕上,就像其他 View 一样。
  • ComposeView 也负责管理 Compose UI 的生命周期,例如在 ComposeView 被从窗口分离或宿主 Lifecycle 销毁时,正确地处置 Compose 组合。

AndroidView

在Compose中,使用传统的View,需要使用 AndroidView 来包裹,这样才能正常显示。

使用示例:

@Composable
fun MyViewInCompose() {
    var counter by remember { mutableStateOf(0) }

    Column {
        Text("Compose counter: $counter")
        AndroidView(
            factory = { context ->
                // 第一次组合时创建并返回一个传统的 Button
                android.widget.Button(context).apply {
                    text = "Click me (View)"
                    setOnClickListener {
                        counter++
                    }
                }
            },
            update = { button ->
                // 每次重组时更新 Button 的文本
                button.text = "Click me (View) - Count: $counter"
            }
        )
    }
}

可以看到 AndroidView 接受一个 factory lambda,这个 lambda 会返回你想要嵌入的 View 实例。这个 factory 只会在首次组合时执行一次。

它还接受一个 update lambda,这个 lambda 会在 Compose 重组时(当 AndroidView 的参数发生变化时)被调用,允许你更新嵌入 View 的属性,以响应 Compose 状态的变化。

AndroidViewBinding

另一个在Compose中使用传统View的方案是 AndroidViewBinding ,使用示例:

// my_layout.xml
// <LinearLayout ...>
//     <TextView android:id="@+id/my_text_view" ... />
// </LinearLayout>

@Composable
fun MyXmlLayoutInCompose() {
    var message by remember { mutableStateOf("Hello from XML!") }

    Column {
        Text("Compose Message: $message")
        AndroidViewBinding(MyLayoutBinding::inflate) {
            // 在这里可以访问 my_text_view
            myTextView.text = message
            myTextView.setOnClickListener {
                message = "XML updated!"
            }
        }
    }
}

这个组件用于嵌入整个 XML 布局文件,AndroidViewBinding 接受一个 View Binding 类的 inflate 方法引用作为参数。也提供了一个 update lambda,你可以在其中访问绑定对象并更新 XML 布局中各个 View 的属性。

AndroidView / AndroidViewBinding 的工作原理

当这些 Composable 被组合时,它们会创建一个传统的 Android View 实例(或一个 View 层次结构)。 Compose 会将这个 View 实例插入到其内部的 LayoutNode 树中,确保它能够参与 Compose 的布局和绘制过程。 尽管这些 View 是传统的 Android View,它们仍然被 Compose 的单遍测量和布局规则所管理。Compose 会向它们传递约束,并根据它们报告的尺寸进行布局。 绘制时,Compose 会在适当的时机调用嵌入 View 的 draw() 方法,并将结果集成到 Compose 自身的渲染中。

Compose和View指标对比

以下来自Google官方文档,有删减。

比较 Compose 指标和 View 指标

APK 大小

将库添加到项目中会增加其 APK 大小。

首次将 Compose 添加到 Sunflower 后,APK 大小从 2,252 KB 增加到 3,034 KB,增加了 782 KB。生成的 APK 包含混合了 View 和 Compose 的界面 build。由于向 Sunflower 添加了其他依赖项,因此出现这种增加是意料之中的。

相反,将 Sunflower 迁移为 仅使用 Compose 的应用 后,APK 大小从 3,034 KB 减少到 2,966 KB,减少了 68 KB。

之所以减少,是因为移除了未使用的 View 依赖项,例如 AppCompat 和 ConstraintLayout。

构建时间

添加 Compose 会增加应用的构建时间,因为 Compose 编译器会处理应用中的可组合项。以下结果是使用独立的 gradle-profiler 工具获得的,该工具会多次执行构建,以便为 Sunflower 的调试 build 时长获取平均构建时间。

首次将 Compose 添加到 Sunflower 时,平均构建时间从 299 毫秒增加到 399 毫秒,增加了 100 毫秒。这是因为 Compose 编译器会执行其他任务来转换项目中定义的 Compose 代码。

相反,在完成 Sunflower 向 Compose 的迁移后,平均构建时间缩短至 342 毫秒,减少了 57 毫秒。构建时间缩短可以归因于多种因素,这些因素共同缩短了构建时间,例如移除数据绑定、将使用 kapt 的依赖项迁移到 KSP,以及将多个依赖项更新到最新版本。

使用基准配置文件帮助Compose实现AOT编译

又可能仅为Google Play实现,国内商店尚未调研。

由于 Jetpack Compose 是未捆绑库,因此它无法受益于 Zygote,后者会 预加载 View 系统的界面工具包类和可绘制对象 。Jetpack Compose 1.0 利用了 release build 的配置文件安装。ProfileInstaller 可让应用指定要在安装时进行预编译 (AOT) 的关键代码。Compose 随附配置文件安装规则,可减少 Compose 应用的启动时间和卡顿。

基准配置文件是加快常见用户体验历程的绝佳方式。在应用中添加基准配置文件可以避免对包含的代码路径执行解译和即时 (JIT) 编译步骤,从而使应用首次启动时的代码执行速度即可提高约 30%。

Jetpack Compose 库包含自己的基准配置文件,当您在应用中使用 Compose 时,系统会自动获取这些优化。不过,这些优化仅会影响 Compose 库内的代码路径,因此我们建议您向应用添加基准配置文件,以涵盖 Compose 之外的代码路径。

跨平台框架Compose Multiplatform的显示渲染

最后一个章节是由Jetbrains维护的Compose跨平台版本,在各个平台上渲染方式的总结。

Compose Multiplatform (CMP) 的核心理念是共享 UI 代码,并尽可能在不同平台上提供原生级别的性能和外观。它实现这一目标的关键在于其底层的渲染机制,尤其是对 Skia 图形库的依赖。

核心原理:Skia 作为跨平台图形引擎

Compose Multiplatform 利用 Skiko (Skia for Kotlin) 这个库,将强大的 Skia 图形库引入到 Kotlin/JVM 和 Kotlin/Native 环境中。Skia 是 Google 开发的一个开源 2D 图形库,用于绘制文本、几何图形和图像。Chrome 浏览器、Android 操作系统、Flutter 等都使用 Skia 进行图形渲染。

这意味着,在大部分支持的平台上,Compose Multiplatform 的 UI 并不是直接映射到平台的原生 UI 组件(例如 Android 上的 View 或 iOS 上的 UIView),而是将 UI 描述转化为 Skia 绘制指令,然后由 Skia 在平台的画布上进行硬件加速渲染。

让我们逐一看看不同平台的渲染方式:

1. Android

  • 渲染方式: Compose Multiplatform 在 Android 平台上直接使用 Jetpack Compose 的渲染机制。Jetpack Compose 内部也会将 Composable 的 UI 描述转换为 RenderNode,然后通过 Android 的 HWUI (Hardware Accelerated UI) 渲染管道,利用 GPU (通常是 OpenGL ES) 进行绘制。
  • 与传统 View 的集成: 如前所述,Compose UI 运行在一个特殊的 ComposeView 中,这个 ComposeView 本身是一个传统的 Android View,它负责承载 Compose 内容并将其绘制到屏幕上。因此,最终 Compose 的绘制内容会通过 ComposeViewCanvas 传递给底层的 Android 渲染系统。
  • 性能: 高性能,得益于 Jetpack Compose 的单遍测量、智能重组以及 Android 硬件加速的渲染管道。

2. iOS

  • 渲染方式: 这是 Compose Multiplatform 最引人注目的突破之一。在 iOS 上,Compose Multiplatform 不使用 UIKit/SwiftUI 原生组件。它利用 Kotlin/Native 技术将 Kotlin 代码编译为原生二进制代码,并通过 Skiko 库将 Compose UI 的绘制指令转化为 Skia 调用。
  • 底层: 最终,这些 Skia 绘制指令会在一个特殊的 UIView (通常是一个 UIViewController 的内容 View) 上进行渲染。这个 UIView 充当一个画布,Compose 通过 Skia 直接在上面绘制所有 UI 元素。
  • 优势: 实现了高度一致的 UI 表现,因为绘制逻辑是共享的。同时,它也提供了与原生 iOS 行为(如滚动物理、文本编辑、辅助功能)的紧密集成,以确保应用感觉原生。
  • 互操作性: 尽管 Compose UI 自身是基于 Skia 绘制的,但它仍然提供了与 UIKit 和 SwiftUI 的互操作性,允许你在 Compose 屏幕中嵌入原生 iOS View,或将 Compose UI 嵌入到现有的 iOS 应用中。
  • 性能: 通过直接利用 GPU 和高效的 Skia 渲染,Compose Multiplatform 在 iOS 上也能实现接近原生的性能。

3. Desktop (macOS, Windows, Linux)

  • 渲染方式: 在桌面平台上,Compose Multiplatform 运行在 JVM 上。它也通过 Skiko 库,将 Compose UI 的绘制指令传递给 Skia。
  • 底层: Skia 会利用底层的图形 API(如 OpenGL、DirectX 或 Vulkan,具体取决于平台和驱动)在桌面窗口中进行硬件加速渲染。
  • 平台特性: Compose Multiplatform for Desktop 提供了桌面平台特有的功能,如窗口管理、菜单栏、系统托盘、文件选择器等,这些功能通常通过平台特定的 API 实现,并与 Compose UI 集成。
  • 性能: 高性能,充分利用桌面硬件加速,提供流畅的 UI 体验。

4. Web (Wasm / JavaScript)

  • 渲染方式: Compose Multiplatform for Web 目标是 Kotlin/Wasm(或之前的 Kotlin/JS),它将 Compose UI 编译为 WebAssembly 或 JavaScript。
  • 底层: 渲染机制主要是将整个 Compose UI 绘制到一个 HTML 的 <canvas> 元素中。同样,这得益于 Skiko 库,Skia 会被编译为 WebAssembly,并在浏览器中进行渲染。
  • 特点:
    • 全屏画布: 你的整个 Compose Multiplatform Web 应用通常被渲染为一个大的画布元素。
    • 非原生 DOM: 这意味着你的 UI 元素不是标准的 HTML DOM 元素(如 <div>, <p>, <button>)。因此,一些传统的 Web 特性(如文本选择、右键上下文菜单、SEO 优化)可能需要额外的处理或适配。
    • 性能: 依赖于浏览器的 <canvas> 性能和 Skia/Wasm 的渲染效率。对于复杂的、动态的 UI,它通常表现良好。
  • Compose HTML (补充): 值得注意的是,JetBrains 还提供了一个独立的库 Compose HTML。Compose HTML 不是 Compose Multiplatform 的一部分,它仅用于 Kotlin/JS,允许你使用 Compose 的声明式 API 来直接构建和操作 HTML DOM 元素。这意味着它能更好地与 Web 的原生特性集成,但不能共享 UI 渲染代码到移动/桌面平台。Compose Multiplatform Web 主要关注基于 Skia 的画布渲染。

总结

Compose Multiplatform 的核心渲染策略是使用 Skia 作为跨平台图形引擎。它将你的声明式 UI 描述转化为 Skia 的绘制指令,然后在每个平台的画布上进行硬件加速渲染。这使得 UI 能够保持高度的一致性,同时通过平台特定的集成层,尽可能地提供原生性能和用户体验。

平台渲染引擎/方式底层技术/API
AndroidJetpack Compose (HWUI)RenderNode, OpenGL ES
iOSSkia (通过 Skiko 库直接绘制到 UIView 画布上)Kotlin/Native, Skia, Metal/OpenGL ES
DesktopSkia (通过 Skiko 库直接绘制到桌面窗口)JVM, Skia, OpenGL/DirectX/Vulkan (取决于平台)
WebSkia (通过 Skiko 库绘制到 HTML <canvas> 元素)Kotlin/Wasm (或 JS), Skia, WebGL

【Compose】Jetpack Compose的MVI架构设计

【Compose】Jetpack Compose的MVI架构设计

本文抽离自内部的一次课题分享,普及Android平台新的声明式开发框架。

课题分享,对不熟悉 Compose 及其架构设计的老师,介绍一下 Compose 这个声明式UI框架,还有其官方推荐的MVI架构最佳实践。

声明式UI框架

‌Jetpack Compose‌是由Google在2019年推出的一个现代化的声明式UI工具包,旨在简化Android UI的开发过程。

其历史和发展可以追溯到2019年Google I/O大会上的公布,并在2021年7月29日正式发布1.0版本‌。

主要特点和优势:

  • ‌声明式编程‌:使用声明式编程范式,代码更简洁、可读性更高‌
  • Kotlin原生支持‌:完全使用Kotlin编写,与Kotlin语言特性无缝集成‌
  • 简化UI开发‌:减少了样板代码,开发者可以更专注于UI逻辑‌
  • 实时预览‌:支持实时预览功能,开发者可以即时查看UI效果‌
  • 强大的社区支持‌:拥有丰富的文档、教程和社区资源‌

目前在较新版本的Android Studio里新建项目,默认排第一位的就是Compose的UI框架的项目。

下面是一个例子,在屏幕中央显示一个文本,并且可以直接在Android Studio的右侧预览实机画面:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier.fillMaxSize(1f),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
    }
}

实时预览:

同View的写法差别

在View命令式UI架构中,对视图的创建,更新等都是设置一条条的命令来进行,每个View都是维护自己的一个状态,并且对外暴露get和set接口来供外接交互。

比如TextView的setText()方法,setBackground()方法,就是更改了这个TextView实例的mText文本,背景属性等。

TextView tvTest = findViewById(R.id.test);

String userName = viewModel.getUserName();

tvTset.setText(userName);

对于Compose这种声明式的Ui架构,不会以对象的方式来提供组建,而是以可组合项的形式来使用。相对来说没有状态,其状态靠外部调用方的变量去维护。

例如显示一个可变字符串:

@Composable
fun ComposeDemo(){
    val textState = remember { mutableStateOf("Hello, Android!") }
    Text(
        text = textState.value,
        modifier = Modifier.padding(16.dp)
    )
}

Text可组合项和 textState 的设计可以理解为观察者模式,另外一个地方对textState进行修改,这个变化可以直接被Text可组合项接收到,并自动更新界面显示状态。

原理就是在编译期,Compose框架就可以分析出会受到这个 textState 变化所影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。下一帧的渲染周期到来之前,触发重组,这个过程中就会执行这些标记 Invalid 的代码块,以达到更改视图内容的目的。

Compose中的一般组件

View中的页面布局,外面使用的是一个个的Layout,像LinearLayout,FrameLayout等。利用ViewGroup来包裹View,在内部按照不同的Layout的特性,给子View设置不同的属性。

例如在LinearLayout中直接设置weight属性来实现分比例布局,在ConstraintLayout里,通过设置startToStart属性来进行相对约束布局设置。

在Compoe中,最常用的布局组件一般有Column,Row,Box几种,最近也增加了ConstraintLayout的Compose版本。

Column行布局,其内部的组件会沿着竖直方从上至下排列。Row则为水平方向从左至右排列。Box则是在原位置上,一层一层地叠加排列。

例如,我要显示一个简单的列表:

@Composable
fun ComposeDemo(){
    val textState = remember { mutableStateOf("Hello, Android!") }
    Column {
        repeat(8) {
            Text(
                text = textState.value,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

Compose可以完美地使用Kotlin语音来编写,布局中可以无缝使用很多方便的api,这里就用到了repeat循环函数。我们可推算出 Text() 这个可组合函数,会被调用了8次,就会在屏幕上显示8个文本。

这在相对静态的View架构中是难以想象的。要显示一个列表视图,即使使用简化后的第三方库,比如像 BaseRecyclerViewAdapterHelper ,也至少需要创建一个 list_item 的xml布局,一个适配器 Adapter 类,有时候还需要写一个 ViewHolder 类。

使用Compose的列表预览效果如下:

视图结构

View视图结构

经典框架不做多余赘述。

Compose视图结构

Composable可组合项在Android平台的实现,是利用 ViewGroup 来显示的,并且最终也是使用到Android的原生控件来显示内容。

通过打印堆栈可以看出,在页面布局的创建阶段,使用到了AndroidComposeView这个类。

ComposeView其实就是一个ViewGroup,它继承自AbstractComposeView,负责对Android平台的Activity的窗口进行适配。取而代之的是AndroidComposeView这个ViewGroup,Composable可组合项的内容就在这里面来渲染显示。

同View架构类似,Compose也是通过一个树形结构SlotTable来管理内部节点LayoutNode的。

View架构通过解析xml文件,得到页面的结构,再对内部组件进行测量布局绘制。Compose架构的第一步被替换为组合阶段,一个个的Composable可组合项,按照写好的声明式代码,添加到SlotTable中。

然后再进行测量放置,绘制。

固有特性测量

谈到Compose架构,这个是绕不过去的话题,固有特性测量的机制,也是为什么Compose可以采用疯狂嵌套而不会指数级影响测量时间的原因。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <View
        android:layout_width="match_parent"
        android:layout_height="48dp" />

</LinearLayout>

这个内部的View并没有给定宽度,而是对齐父控件最大宽度。而父控件的宽度又是 wrap_content 的配置。

这时候, LinearLayout 就会先以 0 为强制宽度测量一下这个子 View,并正常地测量剩下的其他子 View,然后再用其他子 View 里最宽的那个的宽度,二次测量这个 match_parent 的子 View,最终得出它的尺寸,并同时把这个宽度作为自己最终的宽度。

有些场景甚至会有三次及以上的测量。如果是嵌套场景,层级每深一级,测量次数就会以指数级增长。

固有特性测量实际是固有尺寸测量,在父可组合项对内部的子可组合项正式测量之前,会先遍历一遍内部所有的组件,得出他们的固有尺寸,就是显示内容所需的最小和最大尺寸究竟是多少,在正式测量没有给定具体宽高时,就使用这个尺寸来作为最终测量的数据。不会像View架构一样,首次遍历测量完成之后还要再次测量一遍。

使用固有测量获得的参数:

@Composable
fun IntrinsicTest() {
    Column(
        modifier = Modifier
            .height(IntrinsicSize.Min)
            .background(Color.Red)
    ) {
        Text(text = "Hello Test!", modifier = Modifier.fillMaxSize(1f))
    }
}

事件分发机制

Jetpack Compose 和传统的 View 架构在触摸事件分发方面有一些不同。

在 View 架构中,事件分发遵循”责任链”模式,从顶层 ViewGroup 开始,自上而下传递。

各级使用 dispatchTouchEvent()、onInterceptTouchEvent() 和 onTouchEvent() 方法来处理和分发事件。

在 Jetpack Compose 中,使用 Modifier 修饰符来处理触摸事件,没有一个明确的分发链。在Android平台,由于是基于ViewGroup来承载,事件机制经过测试也是类似的自上而下的分发。通过 Modifier.pointerInput() 或 Modifier.clickable() 等修饰符来添加触摸事件监听。

@Composable
fun ComposeDemo() {
    val textState = remember { mutableStateOf("Hello, Compose!") }
    Text(
        text = textState.value,
        fontSize = 70.sp,
        modifier = Modifier
            .padding(20.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        textState.value = "Tap detected"
                    },
                    onDoubleTap = {
                        textState.value = "Double tap detected"
                    },
                    onLongPress = {
                        textState.value = "Long press detected"
                    },
                    onTap = {
                        textState.value = "Tap detected"
                    }
                )
            }
    )
}

双击后的变化:

手势判断的简化

在View架构里想要监听手势,比如要自定义一个View,重写其onTouchEvent,对MOVE事件里的滑动方向进行计算后判断,或者将touch事件传递给GestureDetector对象。

Compose里的手势也有相应的简化,举例一个对手指左滑的监听,同样在pointerInput函数中,需要使用 detectHorizontalDragGestures 函数,根据 dragAmount参数的正负来判断手势方向,然后再进行对应的处理。

@Composable
fun ComposeDemo() {
    val textState = remember { mutableStateOf("Hello, Compose!") }
    Text(
        text = textState.value,
        fontSize = 70.sp,
        modifier = Modifier
            .padding(20.dp)
            .pointerInput(Unit) {
                detectHorizontalDragGestures { _, dragAmount ->
                    if (dragAmount < 0) {
                        // 左滑逻辑
                       textState.value = "Left swipe detected"
                    }
                }
            }
    )
}

同View架构性能对比

整体来看,Compose的性能表现依然比View要差,毕竟View框架已经经过了多年的迭代和优化。主要体现在初始化时长,滑动流畅度还有动画等方面。

初始化显示较慢的原因之一,是Jetpack Compose为了实现了compose和Android版本之间的向后兼容,设计为了一个单独的库,并不包含在Android操作系统中。因此,库中的代码应在首次运行时使用即时(JIT)编译。这使得它在本质上比基于Android View的代码慢,后者是使用的提前编译(AOT)策略,并且二进制文件存储在设备上的操作系统中。

还有过度重组导致的性能问题,在Compose中,当可组合项观测的状态发生变化时,会触发其重组,进一步会使所有相关的可组合项进行重绘。如果状态发生变化的频率非常高,那么就会导致UI频繁地重绘,从而影响性能。

另外Compose的动画实现逻辑,同样基于重组机制,相较于View也更加复杂,性能上会差一些。

优化方案

  1. 尽可能缩小重组范围,遵循Google官方的最小重组范围实践。
  2. 避免过度使用状态,使用状态时,尽量使用不可变的状态。
  3. 使用 mutableStateOf 函数来创建可观察的状态。
  4. 使用 remember 函数来缓存可组合项的状态,重组前后可以保存状态。
  5. 使用LazyColumn和LazyRow时,使用Key来标记每个项,避免扩大重组范围。

协程基础使用

协程是Kotlin为异步任务设计的一个解决方案。在Android平台,其内部依然是基于Handler和线程池。

举例

最常见的使用方式,在 ViewModel 或者 Controller 里写业务逻辑,在 Activity 里调用,这样就可以在IO线程执行网络请求,拿到结果后自动切换到主线程更新UI。

// viewModel或者controller里获取数据逻辑
// 使用suspend限制在协程里使用;withContext切换调度器,指定在IO线程执行下面的任务
suspend fun getUserName() = withContext(Dispatchers.IO) {
    debugLog("thread name: ${Thread.currentThread().name}")
    ServiceCreator.createService<UserService>()
        .getUserName("2cd1e3c5ee3cda5a")
        .execute()
        .body()
}

// Activity调用处
override fun onCreate(savedInstanceState: Bundle?){
    // 最直接的声明方法,在主线程执行下面的逻辑
    lifeCycleScope.launch {
        // 相当于get这一半是在IO线程执行
        //拿到结果后的变量赋值这一半操作由调度器自动切换到主线程来执行了
        val userName = mViewModel.getUserName()
        infoLog("userName: $userName")
        binding.tvUserName.text = userName
    }
}

基础概念

四个主要概念:

  • suspend function。即挂起函数,delay() 就是协程库提供的一个用于实现非阻塞式延时的挂起函数
  • CoroutineScope。即协程作用域,GlobalScope 是 CoroutineScope 的一个实现类,用于指定协程的作用范围,可用于管理多个协程的生命周期,所有协程都需要通过 CoroutineScope 来启动
  • CoroutineContext。即协程上下文,包含多种类型的配置参数。Dispatchers.IO 就是 CoroutineContext 这个抽象概念的一种实现,用于指定协程的运行载体,即用于指定协程要运行在哪类线程上
  • CoroutineBuilder。即协程构建器,协程在 CoroutineScope 的上下文中通过 launch、async 等协程构建器来进行声明并启动。launch、async 均被声明为 CoroutineScope 的扩展方法

挂起函数

内部有耗时逻辑的函数,都可以标记位suspend函数,挂起函数只能在另一个suspend函数或者协程中调用。这是实现协程非阻塞特性的关键。

协程作用域

协程作用域是协程的容器,用于管理协程的生命周期。

  • 顶级作用域:GlobalScope–>全局范围,不会自动结束执行,无法取消。
  • 协同作用域:coroutineScope –>抛出异常会取消父协程
  • 主从作用域:supervisorScope –>抛出异常,往下传递,不会取消父协程

三种作用域真正常用的其实只有主从作用域,谁也不想让自己写的协程挂了导致整个app崩溃。常用的主从作用域我们也肯定接触过:

  • MainScope:主线程的作用域,全局范围,可以取消。
  • lifecycleScope: 生命周期范围,用于activity等有生命周期的组件,在Desroyed的时候会自动结束。
  • viewModelScope:ViewModel范围,用于ViewModel中,在ViewModel被回收时会自动结束。

在设置异常处理时,可以使用 CoroutineExceptionHandler ,作用类似Java的 UncaughtExceptionHandler ,来捕获协程中未捕获的异常。可以兜住其内部子协程所抛出的异常,防止整个app崩溃。

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    // 处理异常
}

lifecycleScope.launch(exceptionHandler) {
    // 协程代码

}

协程上下文

协程上下文是协程的配置参数,用于指定协程的运行载体。即用于指定协程要运行在哪类线程上。

主要使用以下三种:

  • Dispatchers.IO:用于执行IO密集型任务,如网络请求、文件读写等。
  • Dispatchers.Main:用于执行主线程任务,如UI更新、动画等。
  • Dispatchers.Default:用于执行CPU密集型任务,如计算、数据处理等。

协程构建器

协程构建器是协程的声明方式,用于声明并启动协程。常用以下两种

  • launch:用于启动一个新的协程,返回一个 Job 对象,可以通过 Job 对象来控制协程的生命周期。
  • async:用于启动一个新的协程,返回一个 Deferred 对象,可以通过 Deferred 对象来获取协程的返回值。

async方法启动举例:

// 耗时函数
suspend fun returnString(): String {
    delay(3000L)
    return "Hello, Compose!"
}

// 启动协程等待结果
val result = async { returnString() }.await()
println(result)

注意 await() 函数也为挂起函数,在结果返回之前,不会往下执行剩余代码。

MVI架构

进入正题,MVI架构(Model-View-Intent),是一种用于构建用户界面的架构模式,它将应用程序的逻辑分为三个部分:Model、View和Intent。

Model:表示应用程序的数据和状态。它是应用程序的核心,负责管理应用程序的业务逻辑和数据。 View:表示应用程序的用户界面。它负责将Model中的数据呈现给用户,并接收用户的输入。 Intent:表示用户的操作或事件。它是View和Model之间的桥梁,负责将用户的操作转换为Model可以理解的格式。

核心思想是保证唯一可信的单向数据流来更新UI,用户事件自上而下,数据自下而上。

举例

以网络请求一张图片为例,最简单的状态表达,可以设置一个加载态,一个成功后的展示态,一个失败提示。

首先定义状态数据传输的数据类:

data class ImageState(
    val loading: Boolean = false,
    val imageUrl: String? = null,
    val error: String? = null  
)

在数据层设置网络接口,发起网络请求,网络框架选用 JetbrainsKtor

class KtorClient {

    companion object {
        const val TAG = "KtorClient"
    }

    private val client = HttpClient(CIO) {
        install(Logging) {
            level = LogLevel.ALL
        }
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
            })
        }
    }

    suspend fun getOneCatImage() = withContext(Dispatchers.IO) {
        client.get("https://api.thecatapi.com/v1/images/search").body<List<PicKtorItem>>()
    }
}

在ViewModel层,注入 KtorClient ,维护页面加载状态,发起网络请求,获取数据,然后更新状态。

class MainViewModel(private val ktorClient: KtorClient) : ViewModel() {

    private val _imageState = MutableStateFlow(ImageState())
    val imageState: StateFlow<ImageState> = _imageState.asStateFlow()

    fun loadCatPicture() {
        viewModelScope.launch {
            _imageState.value = _imageState.value.copy(loading = true) 
            try {
                val catPictures = ktorClient.getOneCatImage()
                if (catPictures.isNotEmpty()) {
                    _imageState.value = _imageState.value.copy(
                        loading = false,
                        imageUrl = catPictures[0].url
                    ) 
                } 
            } catch (e: Exception) {
                _imageState.value = _imageState.value.copy(
                    loading = false,
                    error = e.message
                ) 
            }
        } 
    }
}

在Activity里,对viewmodel维护的状态的消费与界面展示:

class ComposeTestActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NetDataDemoTheme {
                val mainStateHolder: MainStateHolder by viewModel()
                val imageState = mainStateHolder.imageStateFlow.collectAsState()

                LaunchedEffect(Unit) {
                    mainStateHolder.loadCatPicture()
                }
                
                ImageTest(
                    loading = imageState.value.loading,
                    imageUrl = imageState.value.imageUrl,
                    error = imageState.value.error
                )
            }
        }
    }
}

@Composable
fun ImageTest(loading: Boolean, imageUrl: String?, error: String?) {
    Column {
        Text(text = "loading: $loading, imageUrl: $imageUrl, error: $error")
        AsyncImage(
            model = imageUrl,
            contentDescription = "cat pic",
            modifier = Modifier
                .fillMaxWidth(1f)
                .weight(1f)
        )
    }
}

运行效果:

可以看到各项数据被成功取到并展示,进一步设计界面还可以根据loading, imageUrl, error的不同状态,展示不同的界面。比如loading时加入加载态蒙层,失败后加入toast提示。

链路传递原理

数据类data class

在Kotlin中,数据类(data class)自动为我们提供了一个copy方法。这个方法的主要作用是 创建一个当前对象的副本 ,并且可以 选择性地修改 其中的某些属性。

在上面的例子中,ImageState是一个数据类,使用copy方法可以方便地更新_imageState的状态,而不需要手动创建一个新的ImageState对象并复制所有的属性。

MutableStateFlow

在Kotlin中,StateFlow 是一种响应式数据流,它会保存一个当前值,并且可以在这个值发生变化时通知所有的订阅者。MutableStateFlow 则是可以支持允许你通过 value 属性来修改这个当前值,从而触发更新通知。

在上面的示例中,_imageState 是一个 MutableStateFlow 类型的变量,它被初始化为 ImageState() 的一个实例。这意味着 _imageState 会持有一个 ImageState 类型的对象,并且可以在这个对象的状态发生变化时通知所有订阅者。

在Java语境中,StateFlow的作用甚至用法,都和LiveData几乎完全一致。

asStateFlow

上面已经维护了一个MutableStateFlow的变量,为了防止使用方更改,需要将其转换成一个只读类型的StateFlow。所以下面紧随其后定义了一个 imageStateFlow ,使用 asStateFlow() 方法将其转换成一个只读的StateFlow。

collectAsState

以上在ViewModel里的两个步骤,在View框架的也是可以通用的。使用collect收集Flow数据再操作View更新属性显示界面。例如:

mainStateHolder.imageStateFlow.collect {
    if(it.error!=null){
        Toast.makeText(thisComposeTestActivity, it.error, Toast.LENGTH_SHORT).show()
    }
}

在Jetpack Compose中,对这个Flow使用 collectAsState() ,它主要用于将一个 Flow 类型的数据流转换为一个可观察的 State 对象。

在上面获取图片url的示例中,collectAsState 函数的作用是将 MainStateHolder 类中的 imageStateFlow 这个 Flow 类型的数据流转换为一个 State 对象,这个 State 对象可以在Compose的UI中使用,并且当 imageStateFlow 中的数据发生变化时,Compose会自动重新组合UI以反映这些变化。

具体来说,collectAsState 函数做了以下几件事情:

  • 订阅数据流:它会订阅 imageStateFlow 这个 Flow,开始接收其中的数据更新。
  • 保存最新状态:每当 imageStateFlow 发出一个新的值时,collectAsState 会将这个新值保存到一个 State 对象中。
  • 触发UI更新:由于Compose是响应式的,当 State 对象的值发生变化时,Compose会自动重新组合依赖于这个 State 对象的UI组件,从而实现UI的自动更新。

以上就是简要的关于Jetpack Compose的MVI架构的分享,实际使用中,最好配合依赖注入,模块化等方案进一步解耦,使代码架构更清晰易于维护。

【Compose】Compose自定义视图

【Compose】Compose自定义视图

本文介绍了Jetpack Compose里如何自定义视图

View体系回顾

在View体系中,自定义view的流程已经比较熟悉了。主要有以下几个情景:

  • 第一种,继承于现成的View,比如TextView,ImageView,一般都是自己初始化Paint类,在构造器里初始化,在onDraw里画到画布上。
  • 第二种,直接继承自View,需要考虑wrapcontent和padding属性的特殊配置,因为分析源码发现其ATMOST和EXACTLY属性没有区分,所以要实现wrapcontent就需要在onMeasure里自行判断。
  • 第三种是继承自现成的ViewGroup,像LinearLayout,只需要在布局文件里放置想要的子控件,再到构造方法里初始化,配置即可,一般使用于可大量重用的格式化组件。
  • 第四种是直接继承自ViewGroup,需要自行实现onMeasure和onLayout方法,来达到自己想要的组件效果。这种需要特殊注意子控件的处理。

【Android进阶】Android自定义View

Jetpack Compose自定义视图

Jetpack Compose中对于UI的写法更加简单,也是有两种实现方式,一个是基于现有的Composable函数来组合,二是自己使用Canvas来绘制。

基于现有的Composable函数来组合

Compose的UI是基于函数来实现的,所以我们可以直接使用现有的Composable函数来组合成我们想要的UI。

举例,使用LazyColumn和Text,制作一个上下滑动的时间选择器:

@Composable
fun TimePicker() {
    val hours = (0..23).toList()
    // 扩展列表,前后各添加 2 个空项
    val extendedHours = List(2) { -1 } + hours + List(2) { -1 }
    val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = extendedHours.size / 2) // 初始默认滚动到中间
    val selectedHour by remember { derivedStateOf { calculateCenterItem(lazyListState, extendedHours) } }

    LaunchedEffect(lazyListState) {
        snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
            .map { calculateCenterItem(lazyListState, extendedHours) }
            .distinctUntilChanged()
            .collect { }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Select Hour",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        Box(
            modifier = Modifier
                .background(Color.LightGray, RoundedCornerShape(8.dp))
                .padding(8.dp)
        ) {
            LazyColumn(
                state = lazyListState,
                modifier = Modifier
                    .height(200.dp)
            ) {
                items(extendedHours.size) { index ->
                    val hour = extendedHours[index]
                    if (hour != -1) { // 只显示有效的小时项
                        HourItem(
                            hour = hour,
                            isSelected = hour == selectedHour,
                            selectedHour = selectedHour,
                            onClick = { }
                        )
                    } else {
                        Spacer(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(40.dp) // 空项占位高度
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun HourItem(hour: Int, isSelected: Boolean, selectedHour: Int, onClick: () -> Unit) {
    // 计算当前项与选中项的差值
    val difference = kotlin.math.abs(hour - selectedHour)
    // 根据差值动态计算字体大小
    val fontSize = when (difference) {
        0 -> 30.sp // 选中项最大
        1 -> 24.sp // 靠近选中项
        2 -> 20.sp // 次靠近选中项
        else -> 16.sp // 其他项
    }

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { onClick() }
            .padding(vertical = 8.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (hour < 10) "0$hour" else hour.toString(),
            fontSize = fontSize,
            color = if (isSelected) Color.Blue else Color.Black,
            fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
            textAlign = TextAlign.Center
        )
    }
}

// 计算当前位于屏幕中部的项
private fun calculateCenterItem(lazyListState: LazyListState, extendedHours: List<Int>): Int {
    val layoutInfo = lazyListState.layoutInfo
    val visibleItems = layoutInfo.visibleItemsInfo
    if (visibleItems.isEmpty()) return -1

    val centerY = layoutInfo.viewportStartOffset + layoutInfo.viewportSize.height / 2
    val centerItem = visibleItems.find {
        it.offset <= centerY && it.offset + it.size >= centerY
    } ?: visibleItems.first()

    return extendedHours[centerItem.index]
}

运行截图:

基于Canvas来绘制

Compose中使用Canvas来绘制UI,我们需要使用 Canvas(modifier = Modifier) 来调用Canvas,看看 Canvas 在Compose框架里面的实现:

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

可以看到需要传入一个DrawScope的参数,并传入了 modifier.drawBehind(onDraw) 中,用于在Composable的背景上绘制自定义图形。在这个作用域内,我们可以使用 drawXXX 方法来绘制UI。

以下是 DrawScope 中一些常用的方法:

drawLine: 绘制一条直线。
drawRect: 绘制一个矩形。
drawCircle: 绘制一个圆形。
drawOval: 绘制一个椭圆形。
drawArc: 绘制一个弧形。
drawPath: 绘制一个自定义路径。
drawImage: 绘制一个图像。
drawText: 绘制文本。
drawIntoCanvas: 在画布上执行自定义绘制操作。
clipRect: 裁剪画布到一个矩形区域。
clipPath: 裁剪画布到一个自定义路径。
rotate: 旋转画布。
scale: 缩放画布。
translate: 平移画布。
save: 保存当前画布状态。
restore: 恢复之前保存的画布状态。

采用上面同样的例子,绘制一个时钟表盘:


@Composable
fun Clock() {
    var time= remember { mutableStateOf(Calendar.getInstance()) }

    LaunchedEffect(Unit) {
        while (true) {
            time.value = Calendar.getInstance()
            delay(1000) // 每秒更新一次
        }
    }

    Canvas(modifier = Modifier.fillMaxSize()) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.minDimension / 2 - 20.dp.toPx()

        // 绘制表盘
        drawCircle(color = Color.LightGray, radius = radius, style = Stroke(4.dp.toPx()))

        // 绘制刻度
        for (i in 0..59) {
            val angle = i * 6f
            val length = if (i % 5 == 0) 20.dp.toPx() else 10.dp.toPx()
            val start = Offset(
                center.x + (radius - length) * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + (radius - length) * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            val end = Offset(
                center.x + radius * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + radius * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            drawLine(color = Color.Black, start = start, end = end, strokeWidth = 2.dp.toPx())
        }

        // 绘制时针
        val hour = time.value.get(Calendar.HOUR)
        val minute = time.value.get(Calendar.MINUTE)
        val hourAngle = (hour * 30 + minute * 0.5).toFloat()
        rotate(hourAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.5f),
                strokeWidth = 8.dp.toPx()
            )
        }

        // 绘制分针
        val minuteAngle = (minute * 6).toFloat()
        rotate(minuteAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.7f),
                strokeWidth = 6.dp.toPx()
            )
        }

        // 绘制秒针
        val second = time.value.get(Calendar.SECOND)
        val secondAngle = (second * 6).toFloat()
        rotate(secondAngle) {
            drawLine(
                color = Color.Red,
                start = center,
                end = Offset(center.x, center.y - radius * 0.9f),
                strokeWidth = 4.dp.toPx()
            )
        }

        // 绘制中心圆
        drawCircle(color = Color.Black, radius = 10.dp.toPx())
    }
}

运行截图:

还有一个类似的方法是 Modifier.drawWithContent ,它也可以在Composable的内容上绘制自定义图形,区别可以看成这个方法是在Composable的内容上绘制,而不是在Composable的背景上绘制。

/**
 * Creates a [DrawModifier] that allows the developer to draw before or after the layout's
 * contents. It also allows the modifier to adjust the layout's canvas.
 */
fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this then DrawWithContentElement(onDraw)

里面这个 ContentDrawScope 是继承自 DrawScope 的。

/**
 * Receiver scope for drawing content into a layout, where the content can
 * be drawn between other canvas operations. If [drawContent] is not called,
 * the contents of the layout will not be drawn.
 */
@JvmDefaultWithCompatibility
interface ContentDrawScope : DrawScope {
    /**
     * Causes child drawing operations to run during the `onPaint` lambda.
     */
    fun drawContent()
}

使用举例:

@Composable
fun DrawWithContentExample() {
    Box(
        modifier = Modifier
           .size(200.dp)
           .background(Color.LightGray)
           .drawWithContent {
               drawContent() // 绘制原始内容
               drawCircle(
                   color = Color.Blue,
                   radius = 50f,
                   center = Offset(size.width / 2, size.height / 2)  
               )    
           } 
    )  
}

以上就是对Compose自定义视图两个主要方法的介绍。

Pagination