Thinking in Compose

Jetpack Compose 是 Android 声明式 UI 工具包。它提供了声明式 API,让你无需通过命令式地修改前端视图就能渲染 UI,从而简化了 UI 的编写和维护工作。

声明式编程范式

Android 视图的层次结构是一颗 UI 控件树,当应用状态因用户交互而发生变化时,需要更新 UI 层次结构以显示当前数据。更新 UI 最常见的方式是使用 findViewById() 等函数遍历树,并通过调用 button.setText(String)、container.addChild(View) 或 img.setImageBitmap(Bitmap) 等方法来更改节点,这些方法会改变控件的内部状态。

这种方式属于手动操作视图,会增加出错的可能性。如果一份数据需要在多个地方显示,你可能会忘记更新其中一个显示该数据的视图。当两次更新以意想不到的方式发生冲突时,这还可能导致非法状态。例如,某次更新可能试图修改已经从 UI 中移除的节点。一般来说,使用这种方式,软件维护的复杂度会随着需要更新的视图数量而增加。

在过去几年里,整个行业开始转向声明式 UI 模型,这种模型简化了与构建和更新 UI 相关的工作。它的工作原理是从概念上重新生成整个屏幕,只应用必要的更改。

重新生成整个屏幕存在一个挑战,就时间、算力和电池使用而言,这可能代价高昂。为了降低这种成本,Compose 会智能地选择 UI 中需要重绘的部分。

一个 compose 示例

使用 Compose,你可以通过定义一批接受 data 和 emmit UI 元素的 composable 函数来创建你的 UI。下面是一个示例,Greeting() 函数接受一个 String,emit 一个 Text 组件,这样就可以显示一条问候信息了。

kotlin 复制代码
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                Surface{
                    Greeting("Android")
                }
            }
        }
    }

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

上面的 Greetiing 就是一个 Compose 函数,该函数需要注意的事情包括:

  • 注解,所有的 Composable 函数上一定要有 @Composable 注解,该注解会告诉 Compose 编译器这个函数意图把 data 转变成 UI。
  • 数据输入,Composable 函数可以接受参数,以实现描述 UI 的逻辑。
  • UI显示,该函数在 UI 中显示 Text。 它是通过调用 Text() 这个 Composable 函数来实现的,Composable 函数通过调用别的 Composable 函数来 emit UI 层级。
  • 无返回值,该函数没有返回值,Compose 函数描述目标屏幕的状态,而不是构建 UI 组件。
  • 特性,该函数速度快、幂等且没有 Side-effect。

幂等的意思是使用相同参数多次执行该函数与单次执行效果一样,并且在这个函数里面不能使用全局变量或调用 random() 函数。

一般来说,所有的 Compose 函数都必须使用这些特性编写。

声明式范式的转换

在 Compose 的声明式方式中,组件相对来说是无状态的,并且不暴露 set 或 get 函数。组件不是作为对象暴露出来,通过调用带不同参数的同一个 Composable 函数来更新 UI。

当用户与 UI 交互时,UI 会触发诸如 onClick 之类的事件。这些事件应该通知 App 逻辑,进而改变 App 状态。当状态发生变化时,Composable 函数会被再次被调用(传入的 data 变了),这会导致 UI 元素被重新绘制------这个过程被称为重组。

重组(Recomposition)

在命令式 UI 模型中,你需要调用组件的 set 函数修改它内部的状态来更新 UI。在 Compose 中,你使用新的 data 再调用一次 composable 函数,这样函数会重组------函数 emit 的组件会在需要的情况下使用新 data 进行重绘,Compose framework 可以智能地只重组发生改变的组件。

举个例子,下面是一个显示按钮的 composable 函数:

kotlin 复制代码
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                Surface{
                    var clicks = 0
                    ClickCounter(clicks, { clicks++ })
                }
            }
        }
    }

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

但是这段代码存在严重的问题,当你点击按钮时,clicks 变量确实会自增(clicks++),但是界面上的数字永远不会改变,按钮上的文字始终显示 "I've been clicked 0 times"。

原因是 Compose 的重组机制依赖于"状态对象"的变化来触发。普通的 Int 变量发生变化时,Composable 函数不会收到通知,因此不会重新执行 ClickCounter 来更新 UI。

解决方案是需要使用 Compose 提供的状态容器,通常是 mutableStateOf,并配合 remember 使用,以便在重组时保留状态值。修改后代码如下:

kotlin 复制代码
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTestTheme {
                Surface{
                    var clicks by remember { mutableIntStateOf(0) }
                    ClickCounter(clicks, { clicks++ })
                }
            }
        }
    }

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

在 Jetpack Compose 中,任何需要在 UI 变化时反映出来的数据,都必须存储在状态对象中(如 State<T>),而不能是普通的局部变量。

这样每次按钮被点击,调用方(Surface 函数)会更新 clicks 的值,Compose 会再次调用 ClickCounter 函数,这样 Text 函数就会显示新值,这个过程称为重组。其他不依赖这个值的函数不会重组。

重组就是当 inputs 改变后重新调用 composable 函数的过程,当 Compose 基于新 inputs 进行重组时,它只会调用可能已经改变的函数或 lambda 表达式,跳过别的。通过跳过参数未发生变化的函数或 lambda 表达式,Compose 能够高效地重组。

永远不要依赖执行 Compose 函数所产生的 Side-effect,因为函数的重组可能会被跳过。如果依赖了,用户可能会在你的应用中遇到奇怪且不可预测的行为。Side-effect 是指对 App 其他部分可见的任何变化。例如,以下这些操作都是危险的 Side-effect :

  • 对共享对象执行写入操作;
  • 更新 ViewModel 中的可观察对象;
  • 更新 shared preferences;

Composable 函数执行的频率可能跟每帧画面一样,例如动画被渲染时,Composable 函数运行速度很快,以避免动画过程中出现卡顿。如果需要执行耗时操作(比如从 shared preference 中读取数据),请在后台协程中进行,并将结果值作为参数传递给 composable 函数。

下面这段代码创建了一个 composable 函数来更新 SharedPreferences 中的值。

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            TestTheme {
                Surface {
                    SettingScreen()
                }
            }
        }
    }
}

@Composable
fun SettingScreen() {
    val context = LocalContext.current
    val prefs = context.getSharedPreferences("my_settings", Context.MODE_PRIVATE)

    var switchState by remember {
        mutableStateOf(prefs.getBoolean("notification_switch", false))
    }

    SharedPrefsToggle(
        text = "开启通知",
        value = switchState,
        onValueChanged = { newValue ->
            switchState = newValue
            prefs.edit { putBoolean("notification_switch", newValue) }
        }
    )
}

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(
            text = text,
            modifier = Modifier
                .align(Alignment.CenterVertically)
                .padding(start = 10.dp)
        )
        Checkbox(
            checked = value,
            onCheckedChange = onValueChanged
        )
    }
}

这段代码通过回调传递 value 给 SharedPrefsToggle 函数来触发更新。读取和写入 SharedPreferences 应该移至 ViewModel 的后台协程中,这里为了简单就没有移。

使用 Compose 时需要注意:

  • 重组会尽可能地跳过 composable 函数和 lambda 表达式。
  • 重组是乐观的,可能会被取消。
  • Composable 函数可能会非常频繁地执行,频繁到动画的每一帧。
  • Composable 函数可以并行执行。
  • Composable 函数可以按任意顺序执行。

接下来将介绍如何构建 composable 函数以支持重组。在任何情况下,最佳实践都是保持 composable 函数快速、幂等且无side-effect 。

重组会尽可能多地跳过

当你的 UI 的部分内容无效时,Compose 会尽可能只重组需要更新的部分。这意味着它可能会直接去重新执行某个 Button 的 composable 函数,而不执行 UI 树中更高层级或更低层级的 composable 函数。

每个 composable 函数和 lambda 都可能自行重组。下面的示例展示了在渲染列表时,重组如何能够跳过某些元素:

c 复制代码
/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

这些作用域中的每一个都可能是重组期间唯一要执行的函数。当 header 改变时,Compose 可能会直接执行 Column 的 lambda 代码块,而不执行它的任何父项。并且在执行 Column 时,如果 names 没有变化,Compose 可能会选择不执行 LazyColumn 里面的 item。

同样,所有 Composable 函数或 lambda 表达式都应该没有 Side-effect。当你需要执行 Side-effect 操作时,你应该在回调函数中执行。

重组是乐观的

只要 Compose 认为 composable 函数的参数可能发生了变化,就会开始重组。重组是乐观的,这意味着 Compose 期望在参数再次变化之前完成重组。如果在上次重组结束之前某个参数发生了变化,Compose 可能会取消此次重组,并使用新的参数重新重组。

当重组被取消时,Compose 会丢弃此次重组生成的 UI 树。在你的 composable 函数体中,代码是从上往下执行的,如果你的代码里包含 Side-effect(比如直接修改了一个全局变量、发送了一个网络请求、打印了一条日志),这些代码在重组过程中已经被执行了。即使 Compose 后来决定这次重组作废,UI 不更新,但那些 Side-effect 代码无法撤回,已经执行了,这可能会导致应用状态不一致(比如,UI 没有更新,但是全局变量的值已经变了)。

请确保所有 composable 函数和 lambda 表达式都是幂等的且无 Side-effect 的,以应对乐观重组。

Composable 函数可能会非常频繁地运行。

在某些情况下,composable 函数可能会在 UI 动画的每一帧都执行。如果该函数执行耗时操作,比如从设备存储中读取数据,就可能导致 UI 卡顿。

例如,如果你的小部件尝试读取设备配置,它可能每秒会读取数百次,这会对应用的性能造成灾难性影响。

如果 composable 函数需要数据,可为该数据定义参数。然后,你可以将耗时工作移至 composition 之外的另一个线程,并使用 mutableStateOf 或 LiveData 将结果值作为参数传递给 composable 函数。

Composable 函数可以并行执行

目前 composable 函数还不能并行执行,但你应该以多线程的方式编写 Compose 代码,未来 Compose 可能会支持多线程。

Compose 可以通过并行运行 Composable 函数来优化重组。这将使 Compose 能够利用多个核心,并以较低的优先级运行不在屏幕上的 Composable 函数(先全力保证屏幕上的内容画出来,而那些"不在屏幕上"的组件更新任务,会被安排到低优先级的线程里跑)。

这种优化意味着 Composable 函数可能会在后台线程池中执行。如果一个 composable 函数调用了 ViewModel 上的某个函数,Compose 可能会同时从多个线程调用该函数。

为了确保你的 App 运行正常,所有 Composable 函数都不应有 Side-effect。而从回调中触发的 Side-effect(如 onClick),总是在 UI 线程执行。

当调用 composable 函数时,调用可能发生在与调用者不同的线程上。这意味着应该避免在 composable 函数中修改变量------这既是因为此类代码不是线程安全的,而且这种修改操作本身就被视为一种违规的 Side-effect。

下面是一个示例,展示了一个用于显示列表及计数的 composable 函数:

kotlin 复制代码
@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

这段代码没有 Side-effect,能将输入列表转换为用户界面。对于显示小型列表来说这是很棒的代码。不过,如果在该函数中修改局部变量,那么这段代码就不是线程安全的,也不正确:

kotlin 复制代码
@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0
    
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

在这个示例中,items 会在每次重组时被修改。这可能发生在动画的每一帧,或者列表更新的时候。不管是哪种情况,UI 都会显示错误的 count 。正因为如此,Compose 不支持这样的写入操作;通过禁止这种写入,我们让框架可以切换线程来执行 composable 的 lambda 代码块。

Composable 函数可以按任意顺序执行

如果你查看一个 composable 函数的代码,可能会认为代码是按顺序执行的。但并不能保证这是事实。如果一个 composable 函数包含对其他 composable 函数的调用,这些函数可能会以任意顺序执行。Compose 可以识别出某些 UI 元素比其他元素优先级更高,并优先绘制它们。

例如,假设有这样的代码,用于在 tab layout 中绘制三个 Screen:

kotlin 复制代码
@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

对 StartScreen、MiddleScreen 和 EndScreen 的调用可能会以任意顺序执行。这意味着,你不能让 StartScreen() 设置某个全局变量(一种 Side-effect ),然后在 MiddleScreen() 中使用。相反,这些函数都需要是独立的。


参考:

https://developer.android.com/develop/ui/compose/mental-model

相关推荐
用户69371750013844 小时前
Google 推 AppFunctions:手机上的 AI 终于能自己干活了
android·前端·人工智能
用户69371750013844 小时前
AI让编码变简单,真正拉开差距的是UI设计和产品思考
android·前端·人工智能
zh_xuan4 小时前
Android Jetpack DataStore存储数据
android·android jetpack·datastore
程序员陆业聪4 小时前
在 Android 上跑大模型,你选错引擎了吗?
android
studyForMokey6 小时前
【Android面试】View绘制流程专题
android·面试·职场和发展
jjinl8 小时前
Android 资源说明
android
恋猫de小郭9 小时前
Swift 6.3 正式发布支持 Android ,它能在跨平台发挥什么优势?
android·前端·flutter
一只会跑会跳会发疯的猴子9 小时前
php操作ssl,亲测可用
android·php·ssl
程序员码歌10 小时前
火爆了,一个Skill搞定AI热点自动化:RSS 聚合 + AI 筛选 + 公众号 + 邮件全流程
android·前端·ai编程