安卓(3)-实用篇:UI(1)


2024 年我开始逐渐介入客户端的研发,因此我开始学习客户端的知识。
从服务端开始,转到前端来其实完全代表着我的编码风格的转变。我喜欢 UI 编程那「所见即所得」的惊艳,也喜欢人机交互相关的内容。
做 C 端 App,前端技术实际上更多是一种强行「卷」过来的结果:首先,基础肯定是客户端技术 Android/iOS,但是前端的作用越来越清晰。当然,目前的大环境下走入前端甚至客户端开发通常被认为是一种开倒车的举动。但这种东西谁又说得好呢——难道做算法调优几个版本实验指标波动,亦或者做服务端大半夜被机器人打电话就能让人兴奋了吗?
人总有无知的时候,在已知信息差的时候我们总有一天要为认知买单。所以就让我们为梦想,做出一次不那么受到束缚的选择吧。
这是安卓系列的第一期,它包含以下内容:
安卓(1)-语法基础:Kotlin & Java & TS 对比
安卓(2)-语法基础:Kotlin 常用库(1)
安卓(3)-实用篇:UI(1)
我使用了 AI 来辅助我创作了一些重复性的工作,第一期内容会以相关知识的罗列为主,所以最好的阅读方式是阅读后进行查漏补缺。
希望大家可以喜欢这些教程!

本文是安卓系列的第三个内容,主要介绍了 UI 范式相关的内容,用于构建用户页面。学习了这些内容后,我们就可以使用 Kotlin 和安卓 UI 范式直接上手开发我们的 App 了。

UI 范式

1.1 安卓各 UI 范式简介与区别

XML 主要是一种用于描述布局的标记语言,是传统 Android UI 开发中用于辅助View体系构建布局的工具。View是 Android UI 的核心基础组件体系,是构建传统 Android UI 的基本单元。而 Jetpack Compose 是一种独立的 UI 构建方案,它可以在一定程度上替代传统的View体系和 XML 布局方式。

XML 和View体系一起构成了传统的 Android UI 方案,用于构建从简单到复杂的各种 UI 界面。Jetpack Compose 则是一种新的、现代化的 UI 方案,提供了不同的 UI 构建逻辑和体验,用于满足日益复杂的 Android UI 开发需求,特别是在处理动态 UI 和状态管理方面有出色的表现。

1.2 XML + View: 传统范式

我们来创建一个简单的 Android 界面,界面中有一个垂直方向的线性布局(LinearLayout,属于ViewGroup),在线性布局中包含一个按钮(Button,属于View)和一个列表视图(ListView,属于View,且依赖于Adapter来提供数据展示,这里简化处理)。点击按钮可以在列表中添加一条默认的文本数据。

  1. 创建 XML 布局文件(activity_main.xml)

layout目录下创建activity_main.xml文件:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/add_item_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="添加列表项" />

    <ListView
        android:id="@+id/list_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

我们具体做了什么:

  • 整体定义了一个垂直方向(通过android:orientation="vertical"指定)的线性布局(LinearLayout),它是一个ViewGroup,作为整个界面的根布局容器,用于管理其内部子视图的布局排列方式。
  • 在线性布局内部,定义了一个按钮(Button)和一个列表视图(ListView),它们都是View的具体子类。按钮用于触发添加列表项的操作,列表视图用于展示数据列表。通过android:id属性为它们分别指定了唯一的标识符,方便在代码中通过findViewById方法找到对应的视图对象。
  1. 创建 Activity 类

MainActivity.java

package com.example.myapplication;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ListView;

import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private ListView listView;
    private Button addItemButton;
    private List<String> dataList = new ArrayList<>();
    private ArrayAdapter<String> adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 通过findViewById方法找到XML中定义的视图对象
        listView = findViewById(R.id.list_view);
        addItemButton = findViewById(R.id.add_item_button);

        adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, dataList);
        listView.setAdapter(adapter);

        addItemButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // 点击按钮时添加一条默认文本数据到列表
                dataList.add("新的列表项");
                adapter.notifyDataSetChanged();
            }
        });
    }
}

在这个Activity类中:

  • 首先通过setContentView(R.layout.activity_main)方法加载了之前创建的XML布局文件,这一步使得XML中定义的布局结构和视图组件能够在Activity中生效。
  • 然后使用findViewById方法根据XML中定义的id来获取对应的View对象,也就是获取到了按钮(addItemButton)和列表视图(listView)。这里体现了XMLView的关联,XML只是用于描述布局和视图的定义,而要在代码中操作这些视图,就需要通过findViewById来获取具体的视图实例,它们是一一对应的关系。
  • 接着创建了一个数据列表(dataList)和一个ArrayAdapter,用于将数据适配并展示在ListView中。
  • 最后为按钮设置了点击监听器,当按钮被点击时,向数据列表中添加一条新的数据,并通过adapter.notifyDataSetChanged()通知适配器数据发生了变化,从而使得ListView更新显示内容。

我们可以对这种开发范式进行一个总结:

1.3 Compose: 新范式

1.3.1 传统范式的缺陷

我想前端同学看到传统范式,就会联想到 html + JS 的开发范式吧~

正如大家所想,html + JS 的缺点,XML + Model 层同样存在。例如在我们刚刚的例子中:

  1. 代码分离导致的理解成本增加
    • 在基于 XML 的传统开发方式中,布局定义(XML 文件)和逻辑代码(Java 或 Kotlin 代码)是分离的。例如,在activity_main.xml文件中定义了按钮和列表视图的布局结构,但它们的实际行为(如按钮的点击事件处理和列表视图的数据更新)是在MainActivity类中实现的。
    • 这使得开发者需要在两个不同的地方切换思维来理解整个 UI 的构建和功能实现。对于复杂的界面,可能会有多个 XML 布局文件和大量的代码来处理视图操作,这增加了代码的整体理解成本,尤其是对于新接触项目的开发者来说,可能需要花费更多的时间来梳理布局和逻辑之间的关系。
  2. 缺乏动态性和灵活性
    • XML 布局相对来说是静态的。虽然可以通过代码来修改视图的属性,但对于动态变化较多的 UI 场景,操作起来比较繁琐。
    • 比如在这个案例中,当需要在列表视图中添加新的项时,需要通过操作数据适配器(ArrayAdapter)并调用notifyDataSetChanged方法来更新视图。如果要根据不同的条件动态地改变布局结构,如根据数据量的多少显示或隐藏某些视图,或者改变视图的排列方式,就需要在代码中进行复杂的判断和操作,并且可能需要频繁地修改 XML 布局和对应的逻辑代码。
  3. 编译时错误检查的局限性
    • XML 布局在编译时只能检查基本的语法错误和一些属性的合法性。对于布局结构是否在运行时能够正确地与逻辑代码配合,很难在编译阶段发现问题。
    • 例如,在 XML 中可能正确地定义了一个视图的id,但在代码中可能会因为拼写错误或者忘记调用findViewById方法而导致无法正确获取和操作视图。这种运行时才可能发现的错误增加了调试的难度和时间成本。
  4. 布局嵌套导致的性能和维护问题
    • 当使用 XML 构建复杂的 UI 时,不可避免地会使用到大量的布局嵌套。例如,为了实现特定的布局效果,可能会在一个LinearLayout中嵌套多个RelativeLayout,再在这些布局中包含各种视图。
    • 过多的布局嵌套会影响性能,因为每个布局在绘制时都有一定的开销。而且,从维护的角度来看,复杂的布局嵌套会使代码变得臃肿,当需要修改布局时,可能会牵一发而动全身,影响到其他相关的布局和逻辑部分。

除了 4,可以说 1-3 都是前端在写三件套的时候,非常感同身受的缺陷了。同样的,安卓也为我们准备了类似于前端框架(统一维护 + 组件化 + 数据驱动)的方案,这就是 Jetpack Compose:

Google 官方给出了 Compose 相比于 View 的优越性:Jetpack Compose 使用前后对比为了包含 Jetpack Compose 1.0.0-beta05 的更新内 - 掘金

在使用了 Compose 后,我们发现 APK 大小缩减了 41%,方法数减少了 17%
XML 行数大幅减少了 **76%**。再见了,布局文件,以及 styles、theme 等其他的 XML 文件。
考虑到 Kotlin 编译器与 Compose 编译器插件为我们所做的事情,如位置记忆化、细粒度重组等工作,构建时间能够 减少 29%。可查看:link.juejin.cn

Compose 同时也具备跨平台的功能,并且在多端中保持了较好的 UI 一致性。

1.3.2 Compose 重写功能

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // 使用Surface作为最外层容器,应用主题
            Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp() {
    // 记住一个可变的字符串列表,用于存储列表项数据
    var dataList by remember { mutableStateListOf<String>() }
    Column {
        Button(onClick = {
            // 点击按钮时添加一条默认文本数据到列表
            dataList.add("新的列表项")
        }) {
            Text("添加列表项")
        }
        // 遍历dataList并显示每个列表项
        dataList.forEach { item ->
            Text(text = item)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyApp()
}

不难看出,新方案具备如下优势:

  • 代码简洁性和可读性
    • 在传统的ViewViewGroupXML结合的方式中,需要维护XML布局文件和对应的ActivityFragment中的代码。XML文件用于定义布局结构,而在代码中需要通过findViewById等方式获取视图并设置其行为。这使得代码逻辑分散在不同的地方,增加了代码的复杂度。
    • 而在 Compose 中,所有的 UI 相关代码都集中在可组合函数(如MyApp函数)中。通过组合各种Composable函数(如ColumnButtonText)来构建 UI,并且可以直接在函数内部处理交互逻辑(如按钮的点击事件)。这种声明式的方式使得代码更加简洁和易于理解,开发者可以更直观地看到 UI 的结构和行为是如何定义的。
  • 状态管理的便利性
    • 在传统方式中,当需要更新ListView中的数据时,需要操作Adapter并调用notifyDataSetChanged来通知视图更新。这涉及到多个对象之间的交互,并且容易出错。
    • Compose 通过mutableStateListOfremember等机制使得状态管理更加方便。var dataList by remember { mutableStateListOf<String>() }这行代码创建了一个可被记住的可变状态列表。当列表数据发生变化(如按钮点击添加新项)时,Compose 会自动重新执行依赖于这个状态的 UI 部分(如遍历列表显示每个列表项的Text函数),从而更新 UI。这种自动更新机制减少了手动管理视图更新的工作量,降低了出错的概率。
  • 灵活性和动态 UI 构建
    • 传统的XML布局在构建复杂的动态 UI 时可能会变得很繁琐。例如,如果要根据不同的条件动态地添加或隐藏视图,需要在代码中通过Viewvisibility属性进行控制,并且可能需要修改XML布局结构来适应新的需求。
    • Compose 允许更灵活地构建动态 UI。由于所有的 UI 都是通过函数构建的,可以根据任何运行时条件动态地组合Composable函数。例如,可以根据用户权限或数据状态,轻松地在Column布局中添加或删除组件,或者改变组件的显示顺序,而不需要像传统方式那样考虑复杂的视图层次结构的修改。

1.4 Compose 自定义组件

我们首先用一个常见的例子,对 Compose 自定义组件有一个粗浅的了解:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // 使用Surface作为最外层容器,应用主题
            Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp() {
    // 状态管理部分
    var dataList by remember { mutableStateListOf<String>() }  // 第32行,声明可变状态列表,用于存储列表项数据,属于状态管理部分

    Column {
        // 事件处理部分
        Button(onClick = {
            // 点击按钮时添加一条默认文本数据到列表,第36行,定义按钮点击事件的处理逻辑,属于事件处理部分
            dataList.add("新的列表项")
        }) {
            Text("添加列表项")
        }
        // 遍历dataList并显示每个列表项,这部分涉及到状态管理与UI展示的结合,当dataList状态变化时,UI自动更新
        dataList.forEach { item ->
            Text(text = item)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyApp()
}

1.4.1 状态管理

mutableState

MyApp这个可组合函数中:

  • var dataList by remember { mutableStateListOf<String>() }(第 32 行):这一行声明了一个可变的状态列表dataList,它使用remember来确保在重组过程中能够记住这个状态,并且通过mutableStateListOf创建了一个可以被修改的列表类型的状态。每当这个列表的内容发生变化(比如通过按钮点击添加新元素),Compose 会自动重新执行依赖于这个状态的 UI 部分,也就是下面遍历列表展示每个列表项的Text部分,以此来更新界面显示。这属于状态管理部分,用于管理组件内部数据状态的变化以及和 UI 更新的关联。

变量通过mutableStateListOf或者mutableStateOf包装后,当值发生变化(按钮点击导致列表更新),Compose 会自动重新执行依赖于这个状态的可组合函数(包含Text显示计数的部分),从而更新 UI。

除了基本对象使用mutableStateOf,列表对象使用 mutableStateListOf 以外,它们也可以用于包装一个对象。假设我们有一个自定义的数据类User

data class User(val name: String, val age: Int)
@Composable
fun UserDisplay() {
    var user by remember { mutableStateOf(User("John", 30)) }
    Button(onClick = { user = user.copy(age = user.age + 1) }) {
        Text("User's age: ${user.age}")
    }
}

当按钮点击修改user对象的age属性(通过创建一个新的User对象副本实现),由于user是一个可变状态,UI 会自动更新显示新的年龄。

当然,user 已经不是原来的那个 user 了。所以类似于 React 的对象 State,必须要地址变更才会触发 UI 变更。

这其中有一个细节:remember函数。可以类比于前端的 useMemo

@Composable
fun ComplexLayout() {
    val screenDensity = LocalDensity.current
    val layoutSize by remember(screenDensity) {
        // 复杂的计算尺寸的逻辑,这里简化为返回一个固定值
        mutableStateOf(100.dp.toPx())
    }
    Box(
        modifier = Modifier
           .size(layoutSize)
           .background(Color.Gray)
    )
}

在这里,layoutSize的计算结果通过remember进行缓存。只要screenDensity不变,这个计算结果就不会被重新计算,提高了性能。

LaunchedEffect

当需要从外部获取数据(如网络请求、数据库查询等)来更新 UI 时,可以使用LaunchedEffect。例如:

@Composable
fun DataFetchScreen() {
    val viewModel = viewModel<MyViewModel>()
    val data by viewModel.data.collectAsState()
    LaunchedEffect(Unit) {
        viewModel.fetchData()
    }
    // 根据获取到的数据展示UI
    data.forEach { item ->
        Text(item)
    }
}

LaunchedEffect在可组合函数首次组合或者其依赖的键发生变化时,触发viewModel.fetchData()来获取数据,当数据获取成功并更新viewModel中的数据状态后,由于collectAsState会收集这个状态变化,依赖这个状态的 UI 部分(显示数据的Text组件)会自动更新。

MVVM 范式

接上个例子,我们如果想把 UI 和 Model 分离,我们可以通过观察 ViewModel 中的状态并传递给可组合函数。

在 Activity 中,通常会使用 ViewModel 来管理数据状态。例如,定义一个包含数据状态的 ViewModel:

class MyViewModel : ViewModel() {
    val data = MutableLiveData<List<String>>()
    fun fetchData() {
        // 模拟获取数据并更新状态
        val newData = listOf("Data 1", "Data 2")
        data.value = newData
    }
}
  • 然后在 Activity 的setContent方法中,将 ViewModel 中的状态传递给可组合函数:
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel = viewModel<MyViewModel>()
        setContent {
            val data by viewModel.data.observeAsState(listOf())
            MyComposable(data)
        }
    }
}
  • 当 ViewModel 中的data状态发生变化时,传递给可组合函数MyComposable的数据也会改变,进而触发可组合函数内部的重组(如果数据被用于 UI 显示并正确处理了状态变化),从而更新 Activity 中的 UI。

1.4.2 参数传递

我们在状态管理中,已经提及了将 ViewModel 中的状态传递给可组合函数。那么参数传递也能控制 UI 暂时吗?

参数不一定能直接控制 UI 变更:虽然参数在很多情况下会影响 UI,但并非所有参数的改变都必然导致 UI 变更。这取决于 Compose 的优化机制和函数内部的实现逻辑。例如,如果一个参数只是在函数内部用于一些计算,但不直接影响到最终绘制的 UI 元素,那么它的变化可能不会触发 UI 更改。

在我们的简单示例中,并没有明显体现外部参数传递,不过可以设想一下扩展情况。比如,如果想让按钮的文本内容可以由外部传入来定制,那么可以这样修改代码:

@Composable
fun MyApp(customButtonText: String = "添加列表项") {  // 新增参数,并有默认值,外部可传入自定义文本,此处属于参数传递部分,用于接收外部传入参数并设置默认值
    var dataList by remember { mutableStateListOf<String>() }
    Column {
        Button(onClick = {
            dataList.add("新的列表项")
        }) {
            Text(customButtonText)  // 使用传入的参数作为按钮文本
        }
        dataList.forEach { item ->
            Text(text = item)
        }
    }
}

在上述修改后的代码中,MyApp函数新增了customButtonText参数,外部调用MyApp函数时可以传入一个字符串来指定按钮上显示的文本,如果不传则使用默认值"添加列表项",这展示了参数传递部分,通过接收外部参数来定制组件的部分表现形式。

当参数是基本数据类型(如 IntBooleanString 等)且在可组合函数内部被用于确定 UI 的显示内容或布局等关键方面时,参数的变化一般会导致 UI 随之变更。例如,我们刚刚的例子,String 的对象直接触发了更新。

然而,如果参数在函数内部没有被正确地关联到 UI 元素的更新逻辑上,即使参数变化了,UI 也可能不会更新。

对象参数的情况较为复杂:如果参数是一个对象,仅仅是对象内部某些属性的改变,并不一定会导致 UI 变更。Compose 主要是通过对可组合函数的输入进行比较来判断是否需要重组 UI。对于对象参数,默认情况下是基于对象的引用(地址)进行比较的。只有当对象的引用发生变化时,Compose 才会认为输入发生了实质性改变,从而可能触发 UI 重组。

但如果在可组合函数内部手动实现了对对象属性变化的监测和相应的 UI 更新逻辑,那么即使对象地址不变,只要其关键属性发生改变,也可以实现 UI 的更新。例如,使用 mutableStateOf 包装对象或其属性,并在可组合函数中正确处理状态变化,就可以在对象内部属性改变时更新 UI。我们刚刚的 MVVM 例子,就属于此类。所以,在 Compose 中,Model 和 UI 可以充分解耦,比 React 确实更先进。

1.4.3 事件处理

在按钮的定义部分:
Button(onClick = { dataList.add("新的列表项") }) { Text("添加列表项") }(第 36 行左右):这里定义了按钮组件的点击事件处理逻辑。当按钮被点击时,会执行onClick参数所对应的代码块,也就是向dataList中添加一个新的字符串元素。这个操作改变了前面提到的状态列表,进而触发 UI 的更新。这就是典型的用户交互事件处理,属于事件处理部分,用于定义组件如何响应各种用户操作行为。

以下是常用的事件处理表格:

事件名称描述示例代码
onClick用于处理点击事件,当用户点击组件时触发相应逻辑
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
@Composable
fun ButtonExample() {
    var clickCount: Int by remember { mutableStateOf(0) }
    Box {
        Button(
            onClick = {
                // 这里是点击按钮后的代码块
                clickCount++
            }
        ) {
            // 这里是按钮显示文本的代码块
            Text("点击了 $clickCount 次")
        }
    }
}

|
|onLongClick |处理长按事件,用户长按组件时执行特定操作 |

@Composable
fun LongClickExample() {
    var longClickMessage by remember { mutableStateOf("未长按") }
    Box(
        modifier = Modifier
         .size(100.dp)
         .background(Color.Gray)
         .onLongClick { longClickMessage = "长按了组件" }
    ) {
        Text(longClickMessage)
    }
}

|
|onScroll |当组件可滚动且发生滚动操作时触发,可获取滚动状态信息 |

@Composable
fun ScrollExample() {
    val scrollState = rememberScrollState()
    Column(modifier = Modifier
     .fillMaxSize()
     .verticalScroll(scrollState)) {
        repeat(20) {
            Text("第 $it 项")
        }
        Text("当前滚动偏移量: ${scrollState.value}")
    }
}

|
|onDrag |处理拖动事件,用于实现可拖动组件的交互逻辑 |

@Composable
fun DragExample() {
    var dragPosition by remember { mutableStateOf(Offset.Zero) }
    Box(
        modifier = Modifier
          .size(50.dp)
          .offset { dragPosition }
          .background(Color.Blue)
          .draggable(
                orientation = Orientation.Horizontal,
                onDrag = { change ->
                    dragPosition += change
                }
            )
    ) {
        // 这里可以添加Box的内容,如果不需要可以保留空块
    }
}

|

1.4.4 组合和复用

MyApp函数中:
Column {... }(从第 35 行开始到结尾部分):这里使用Column布局组件来组合内部的按钮和列表项显示部分。Column会将其内部的子组件按照垂直方向依次排列,通过这种方式将按钮和列表展示逻辑组合在一起,形成了一个相对完整的自定义组件功能。并且这个MyApp函数本身就可以在多个地方被复用,比如在不同的ActivitysetContent方法中调用,或者在其他更复杂的可组合函数中作为一个子组件被嵌入使用,体现了组件的复用性。通过合理地组合内部组件以及设计良好的函数接口,使得整个组件可以方便地在不同场景下被重复利用,这就是组合和复用部分的体现。

1.4.5 渲染控制

条件:if

在 Jetpack Compose 中,if语句可以根据条件来决定是否渲染某个组件或者组件的一部分。

基于之前的例子,假设我们想要根据列表dataList是否为空来显示不同的提示信息。修改后的MyApp函数如下:

@Composable
fun MyApp() {
    var dataList by remember { mutableStateListOf<String>() }
    Column {
        Button(onClick = {
            dataList.add("新的列表项")
        }) {
            Text("添加列表项")
        }
        if (dataList.isEmpty()) {
            Text("列表为空")
        } else {
            dataList.forEach { item ->
                Text(text = item)
            }
        }
    }
}
  • dataList为空时(dataList.isEmpty()true),只会渲染一个显示 “列表为空” 的Text组件。
  • dataList不为空时,就会遍历dataList并渲染每个列表项对应的Text组件。这种if语句的使用方式使得 UI 的渲染可以根据数据状态的不同而灵活变化,提供了一种简单有效的条件渲染控制。
遍历:forEach

在之前的例子中,forEach已经用于遍历dataList来渲染列表项。

dataList.forEach { item ->
    Text(text = item)
}
  • 在这里,forEach会对dataList中的每个元素执行一次代码块。对于每个元素,都会创建一个Text组件来显示该元素的内容。这种方式在处理列表、数组等集合类型的数据时非常有用,可以方便地根据集合中的元素数量和内容来动态渲染 UI 组件。
  • dataList的内容发生变化(例如,通过按钮点击添加了新的元素),Compose 会检测到dataList这个状态的变化,并且重新执行依赖于这个状态的forEach部分,从而更新 UI,显示新的列表项。这展示了forEach在与状态管理结合时,如何有效地控制组件的渲染,以适应数据的动态变化。

1.4.6 布局组件

布局组件功能描述示例场景示例代码(关键部分)
Column子组件按垂直方向排列用户信息展示(用户名、头像、简介垂直堆叠)
@Composable
fun MyColumn() {
    Column {
        Text(text = "第一行文本")
        Text(text = "第二行文本")
        Text(text = "第三行文本")
    }
}

|
|Row |子组件按水平方向排列 |工具条中多个按钮排列(如搜索、设置按钮) |

Row {
    Button(onClick = { /* 点击事件处理 */ }) {
        Text("按钮1")
    }
    Button(onClick = { /* 点击事件处理 */ }) {
        Text("按钮2")
    }
}

|
|Box |子组件堆叠,通过Modifier控制位置和对齐 |加载动画覆盖在内容之上 |

Box(
    modifier = Modifier.fillMaxSize()
) {
    Text(
        text = "这是内容文本",
        modifier = Modifier.align(Alignment.Center)
    )
    CircularProgressIndicator(
        modifier = Modifier.align(Alignment.TopEnd)
    )
}

|
|ConstraintLayout(Compose版) |通过定义子组件间约束关系确定位置,用于复杂布局 |制作有重叠元素和复杂对齐要求的表单 |

ConstraintLayout(
    modifier = Modifier.fillMaxSize()
) {
    val (button, text) = createRefs()
    Button(
        onClick = { /* 点击事件处理 */ },
        modifier = Modifier.constrainAs(button) {
            top.linkTo(parent.top)
            start.linkTo(parent.start)
        }
    ) {
        Text("按钮")
    }
    Text(
        "文本内容",
        modifier = Modifier.constrainAs(text) {
            top.linkTo(button.bottom)
            start.linkTo(parent.start)
        }
    )
}

|
|LazyColumn/LazyRow |懒加载,仅加载屏幕可见子组件,用于长列表或长布局 |新闻列表应用(滚动加载新闻项) |

LazyColumn {
    val dataList = listOf("数据1", "数据2", "数据3", "数据4", "数据5")
    items(dataList.size) { index ->
        Text(dataList[index])
    }
}

|

1.5 深入学习 Compose

1.5.1 常见概念解释

可组合函数(Composable Functions)
  • 定义与用途:这是 Compose 的核心概念。可组合函数是用 Kotlin 编写的函数,通过@Composable注解标记。它们用于描述 UI 的一部分,就像构建 UI 的积木块。例如,一个简单的显示文本的可组合函数如下:
@Composable
fun MyText() {
    Text("Hello, Compose!")
}
  • 组合方式:可组合函数可以互相组合来构建复杂的 UI。比如,在一个垂直布局的 UI 中,可以将多个不同的可组合函数组合在一起,像这样:
@Composable
fun MyComplexUI() {
    Column {
        MyText()
        AnotherComposableFunction()
    }
}

理解可组合函数是学习 Compose 开发的基础,因为所有的 UI 构建都是围绕它们展开的。它们使得 UI 开发更具模块化和可维护性,每个函数负责特定的 UI 部分,便于复用和更新。

状态(State)和重组(Recomposition)
  • 状态管理:在 Compose 中,状态是驱动 UI 更新的关键。状态可以是简单的数据类型(如布尔值、整数、字符串等)或复杂的数据结构(如列表、自定义对象等)。使用mutableStateOfmutableStateListOf等函数来创建可变状态。例如:
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}
  • 重组机制:当状态发生变化时,Compose 会自动重新执行依赖于该状态的可组合函数,这就是重组。重组是高效的,Compose 会尽可能地只更新受影响的 UI 部分。例如,在上述计数器的例子中,每次点击按钮,count状态改变,包含count显示的Text组件所在的可组合函数会重新执行,更新 UI 显示新的计数。
布局(Layout)组件
  • 基本布局组件:Compose 提供了多种布局组件,如Column(垂直布局)、Row(水平布局)、Box(简单的堆叠布局)等。这些布局组件用于控制子组件的排列方式。例如,使用Column可以将多个组件垂直排列:
@Composable
fun VerticalLayout() {
    Column {
        Text("Top Text")
        Text("Bottom Text")
    }
}
  • 嵌套布局:布局组件可以相互嵌套来实现更复杂的布局。例如,在一个Row布局中嵌套Column布局来创建一个类似表格的结构。理解布局组件的使用和嵌套规则对于构建各种复杂程度的 UI 界面至关重要。
材质设计(Material Design)组件和主题(Theme)
  • 材质设计组件:Compose 支持 Material Design 规范,提供了许多符合该规范的组件,如ButtonTextFieldCard等。这些组件不仅具有预定义的外观,还遵循一定的交互规则,使得应用具有一致的、符合设计标准的用户体验。例如,Button组件在不同状态(如按下、禁用等)下有相应的视觉反馈。
  • 主题应用:主题用于定义应用的整体颜色、字体、形状等外观属性。通过设置主题,可以确保应用中的所有组件具有统一的风格。例如,可以在主题中定义主色调、次色调,然后所有的ButtonText等组件会根据主题来应用相应的颜色。

我们创建一个简单的界面,包含一个带有特定主题颜色的卡片(Card)组件,卡片中有一个标题(Text)和一个按钮(Button),按钮的颜色也根据主题来设定:

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun MyMaterialUI() {
    // 定义一个卡片组件,使用主题中的颜色
    Card(
        // 卡片的背景颜色使用主题中的表面颜色
        backgroundColor = MaterialTheme.colorScheme.surface
    ) {
        Column {
            // 标题文本,颜色使用主题中的主色调
            Text(
                text = "这是一个标题",
                color = MaterialTheme.colorScheme.primary
            )
            Button(
                // 按钮的背景颜色使用主题中的主色调
                onClick = { /* 按钮点击事件处理,这里暂为空 */ },
                color = MaterialTheme.colorScheme.primary
            ) {
                Text("点击我")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyMaterialUI()
}

在这个例子中,MaterialTheme.colorScheme用于获取主题相关的颜色方案surface颜色用于设置卡片的背景色,primary颜色用于设置标题文本和按钮的颜色。这样,通过主题可以统一管理整个应用的颜色风格,当需要修改主题颜色时,只需要在主题定义处修改相关的颜色值,所有使用该主题颜色的组件都会自动更新。

在这个例子中,我们使用了CardTextButton这三个符合 Material Design 规范的组件。Card组件提供了一种带有阴影效果的容器,用于突出显示内容。Text组件用于显示文本,其颜色等属性可以根据主题灵活设置。Button组件具有默认的外观和交互行为,如按下时的视觉反馈等,符合 Material Design 的设计原则,为用户提供了一致的操作体验。

副作用(Side Effects)函数(如 LaunchedEffect、DisposableEffect)

用途:在 Compose 中,副作用函数用于处理一些与外部系统交互或者有额外影响的操作。例如,LaunchedEffect用于在可组合函数内部启动协程,以执行异步操作,如从网络获取数据、进行动画播放等。

我们创建一个界面,在界面加载时从网络获取用户信息,并在获取成功后显示用户的姓名和邮箱:

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL

@Composable
fun UserInfoScreen() {
    val context = LocalContext.current
    val (userName, setUserName) = remember { mutableStateOf("") }
    val (userEmail, setUserEmail) = remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        val userInfo = withContext(Dispatchers.IO) {
            try {
                // 模拟从网络获取用户信息,这里使用一个简单的URL示例
                val url = URL("https://example.com/user_info.json")
                val connection = url.openConnection()
                connection.connect()
                val inputStream = connection.getInputStream()
                // 解析JSON数据获取用户姓名和邮箱,这里简化处理,假设数据格式简单
                val userInfoString = inputStream.bufferedReader().readLine()
                val userInfoArray = userInfoString.split(",")
                Pair(userInfoArray[0], userInfoArray[1])
            } catch (e: Exception) {
                Pair("", "")
            }
        }
        setUserName(userInfo.first)
        setUserEmail(userInfo.second)
    }

    Column {
        Text("用户姓名: $userName")
        Text("用户邮箱: $userEmail")
    }
}
  • LaunchedEffect的作用:在UserInfoScreen这个可组合函数中,LaunchedEffect(Unit)表示这个副作用只会在函数首次组合时触发一次(因为键是Unit)。在LaunchedEffect内部,通过withContext(Dispatchers.IO)在协程的IO调度器下执行从网络获取用户信息的操作。这是因为网络操作是一个耗时的IO操作,不能阻塞主线程。
  • 状态更新和 UI 更新:获取到用户信息后,通过setUserNamesetUserEmail来更新对应的状态。由于这些状态被Text组件所依赖,当状态更新时,Compose 会自动重新执行UserInfoScreen函数中依赖于这些状态的部分(也就是显示用户姓名和邮箱的Text组件),从而更新 UI,将获取到的用户信息显示出来。这样就实现了在界面加载时获取数据并更新 UI 的功能,利用LaunchedEffect处理了网络获取数据这个有副作用的操作。

1.5.2 修饰符

  1. Compose 修饰符的分类

    • 布局修饰符:用于控制组件的布局方式,如sizepaddingfillMaxSize等。这些修饰符可以改变组件的大小、间距和填充等布局属性。
    • 外观修饰符:主要影响组件的外观,像backgroundborderalpha等。它们用于设置组件的背景颜色、边框以及透明度等外观特征。
    • 行为修饰符:用于处理组件的行为相关属性,例如clickabledraggablefocusable等。这些修饰符使组件能够响应点击、拖动、获取焦点等用户交互行为。
    • 组合修饰符:用于组合其他修饰符或者创建新的修饰符逻辑,比如thenwrapContentSize等。
  2. 基本使用

  • 布局修饰符示例 - size
    • 功能:用于指定组件的宽度和高度。
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun SizedText() {
    Text("这是一个有固定大小的文本", modifier = size(100.dp, 50.dp))
}

在这个例子中,size(100.dp, 50.dp)修饰符应用于Text组件,使得文本显示区域的宽度为100dp,高度为50dp

  • 外观修饰符示例 - background
    • 功能:设置组件的背景颜色。
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
def BackgroundBox() {
    Box(
        modifier = Modifier
           .size(200.dp)
           .background(Color.Green)
    ) {
        Text("绿色背景的盒子")
    }
}

解释:这里的Box组件使用background(Color.Green)修饰符,将其背景颜色设置为绿色。size修饰符用于确定Box的大小,以便更好地展示背景颜色效果。

  1. 链式调用 + 效果叠加
  • 示例:组合行为和外观修饰符实现交互效果叠加
    • (链式调用)同时应用多个修饰符来实现复杂的效果,如设置大小并添加背景颜色。
    • (效果叠加)当用户与组件交互时,同时改变外观和行为响应。
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
def ModifiersOverlayBox() {
    Box(
        modifier = Modifier
           .size(120.dp)
           .background(Color.LightGray)
           .clickable { /* 点击事件处理 */ }
           .border(1.dp, Color.Black)
    ) {
        Text("可点击、有边框且有背景色的盒子")
    }
}

在这个例子中,Box组件具有灰色背景、黑色边框,并且添加了点击行为。这些修饰符的效果叠加在一起,当用户看到这个组件时,它有特定的外观,当用户与之交互(点击)时,又会触发相应的行为。这种效果叠加方式使得可以通过组合多个修饰符来创建丰富的、具有交互性的组件。

1.5.3 副作用

在 1.4 中,我们有提到 LaunchedEffect,它属于副作用的一种。

在安卓 Jetpack Compose 中,副作用是指在可组合函数内部执行的,会对外部环境产生影响或者依赖于外部环境的操作。由于 Compose 的可组合函数主要是用于描述 UI 的声明式函数,理想情况下应该是无副作用的纯函数。但在实际应用中,像与外部系统交互(如网络请求、读取本地文件)、修改全局状态等操作都属于副作用。

LaunchedEffect

区别与用途:LaunchedEffect是用于在 Compose 中执行挂起函数(通常是异步操作)的副作用函数。它会在可组合函数首次被组合或者其键(keys)发生改变时启动一个协程来执行相应的操作。主要用于处理一次性的异步操作,如在界面加载时从网络获取数据、初始化动画等。

示例:假设我们有一个界面需要在加载时从网络获取用户信息并显示。

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL

@Composable
fun UserInfoScreen() {
    val context = LocalContext.current
    val (userName, setUserName) = remember { mutableStateOf("") }
    val (userEmail, setUserEmail) = remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        val userInfo = withContext(Dispatchers.IO) {
            try {
                // 模拟从网络获取用户信息,这里使用一个简单的URL示例
                val url = URL("https://example.com/user_info.json")
                val connection = url.openConnection()
                connection.connect()
                val inputStream = connection.getInputStream()
                // 解析JSON数据获取用户姓名和邮箱,这里简化处理,假设数据格式简单
                val userInfoString = inputStream.bufferedReader().readLine()
                val userInfoArray = userInfoString.split(",")
                Pair(userInfoArray[0], userInfoArray[1])
            } catch (e: Exception) {
                Pair("", "")
            }
        }
        setUserName(userInfo.first)
        setUserEmail(userInfo.second)
    }

    Column {
        Text("用户姓名: $userName")
        Text("用户邮箱: $userEmail")
    }
}

在这个例子中,LaunchedEffect(Unit)表示这个副作用只会在UserInfoScreen可组合函数首次组合时触发一次(因为键是Unit)。在LaunchedEffect内部,通过withContext(Dispatchers.IO)在协程的IO调度器下执行从网络获取用户信息的操作。获取到用户信息后,通过setUserNamesetUserEmail来更新对应的状态,从而更新 UI 显示用户信息。

DisposableEffect

区别与用途:DisposableEffect主要用于在可组合函数被销毁或者其键(keys)发生改变时,执行清理资源的操作。这对于避免资源泄漏(如取消订阅、关闭文件流等)非常重要。

示例:假设我们在一个可组合函数中订阅了一个数据流,当可组合函数不再需要时(例如界面被关闭),需要取消订阅以避免内存泄漏。

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

@Composable
fun DataStreamScreen() {
    val dataList = remember { mutableStateListOf<String>() }
    val dataFlow: Flow<String> = flow {
        // 模拟一个简单的数据流,不断发送数字字符串
        var i = 0
        while (true) {
            emit("Data $i")
            i++
        }
    }

    val job = remember {
        LaunchedEffect(dataFlow) {
            val job = kotlinx.coroutines.launch {
                dataFlow.collect { value ->
                    dataList.add(value)
                }
            }
            onDispose {
                job.cancel()
            }
        }
    }

    Column {
        dataList.forEach { item ->
            Text(item)
        }
    }
}

在这个例子中,首先创建了一个dataFlow数据流,然后在LaunchedEffect中启动一个协程来收集这个数据流的数据并添加到dataList中。DisposableEffect通过onDispose块来确保在LaunchedEffect中的协程任务(用于收集流数据)在可组合函数不再需要时(例如,界面关闭或者dataFlow发生变化)被取消,从而正确地释放资源,避免内存泄漏。

1.5.4 智能重组

从本质上来说,Compose 依赖其 SDK 与编译器,Kotlin 依赖编译器插件的模式来进行实现。Compose 通几个注解帮助编译器进一步识别不会重组的 UI 对象。具体有如下几种:

@ReadOnlyComposable

ReadOnlyComposable注解用于标记那些不会修改任何状态的可组合函数。这些函数在被调用时,只是单纯地根据传入的参数构建 UI,不会产生副作用或者改变任何可观察的状态。

当 Compose 进行重组分析时,知道一个被标记为ReadOnlyComposable的函数是无状态的,就可以在某些情况下避免不必要的重新执行。例如,如果这个函数只是用于简单地格式化和显示文本内容,并且不依赖于任何可能变化的状态,那么在其他状态发生变化时,Compose 可以跳过对这个函数的重新执行,从而提高重组效率。

@ReadOnlyComposable
@Composable
fun FormattedText(text: String) {
    Text(text.uppercase())
}

在这个例子中,FormattedText函数只是将传入的文本转换为大写并显示,不会修改任何状态。Compose 在重组过程中,如果发现只有其他无关状态发生变化,就可以不重新执行这个函数,因为它的输出只依赖于传入的text参数,而这个参数在没有改变的情况下,UI 显示也不会改变。

@Immutable

Immutable注解用于标记数据类型,表示这个数据类型是不可变的。当一个数据类型被标记为Immutable后,Compose 可以更有效地处理对这个数据的引用。例如,对于一个不可变的数据类,一旦创建,其内部的属性就不能被修改。

在智能重组的场景下,Compose 需要比较数据是否发生变化来决定是否重新执行相关的可组合函数。对于Immutable类型的数据,比较过程更加简单和高效。因为不可变数据的属性不会改变,所以 Compose 可以通过引用比较或者简单的内容比较(如果数据结构简单)来快速确定数据是否发生了变化。如果数据没有变化,依赖于这个数据的可组合函数部分就不需要重组。

@Immutable
data class UserInfo(val name: String, val age: Int)

@Composable
fun UserInfoDisplay(userInfo: UserInfo) {
    Column {
        Text("姓名: ${userInfo.name}")
        Text("年龄: ${userInfo.age}")
    }
}

在这个例子中,UserInfo数据类被标记为Immutable。当UserInfoDisplay可组合函数的userInfo参数发生变化时,Compose 可以通过比较UserInfo对象的引用或者简单的属性比较(因为属性不可变)来快速判断是否需要重新执行这个函数。如果只是其他无关状态改变,而userInfo没有变化,就不需要重组UserInfoDisplay函数部分的 UI。

@Stable

Stable注解用于标记类型或者函数,表示它们在 Compose 的重组过程中是稳定的。对于一个被标记为Stable的对象,Compose 可以假设它的内容或者行为在没有明显变化提示的情况下是不变的。这包括对象的属性(如果是一个类)以及它所产生的副作用(如果是一个函数)。

Immutable类似,Stable帮助 Compose 在重组分析中做出更高效的决策。如果一个可组合函数的参数是Stable类型,并且在没有明确的变化信号(如状态改变)时,Compose 可以减少对这个函数的重新检查和重新执行。这有助于优化重组过程,避免不必要的 UI 更新。

@Stable
class MyStableObject {
    val value: String = "稳定的值"
}

@Composable
fun MyStableObjectDisplay(myObject: MyStableObject) {
    Text(myObject.value)
}

在这个例子中,MyStableObject类被标记为StableMyStableObjectDisplay可组合函数依赖于MyStableObject。在重组过程中,Compose 会认为MyStableObject是稳定的,只要没有明确的变化提示(如通过状态管理机制通知变化),就不会轻易重新执行MyStableObjectDisplay函数,从而提高了智能重组的效率。

区别 & 使用场景
标记类型关注重点定义描述示例使用场景
ReadOnlyComposable函数行为标记可组合函数特性,该函数不会修改任何状态,从函数执行角度强调纯净性,仅根据传入参数构建 UI,无状态修改和副作用一个将输入文本转换为大写并显示的函数,只进行数据转换和 UI 展示,不影响外部状态1. 纯展示型函数,如格式化日期并显示在文本组件中。
2. 无状态的 UI 组件构建,如固定样式的图标组件
Immutable数据类型的不可变性用于描述数据类型,被标记的数据类型创建后内部属性不能被修改,是对数据结构本身性质的定义一个简单数据类,属性为val且无修改属性方法,整个生命周期内稳定,除非重新创建1. 配置数据传递,如应用的主题配置数据类。
2. 缓存数据和常量数据结构,如预定义的菜单选项列表
Stable综合的稳定性可用于标记类型(如类)或函数。标记类型时表示在重组过程中的稳定性,包括属性和行为;标记函数时表示副作用和返回值等方面的稳定性1. 具有复杂内部状态但对外表现稳定的用户配置对象。
2. 根据系统时间和固定时间间隔决定是否显示提示信息的函数,在相关条件不变时行为稳定
1. 复杂但稳定的对象,如包含多个属性和内部计算逻辑的用户配置对象。
2. 有条件稳定的函数,如上述按特定条件决定显示提示信息的函数

1.5.5 性能优化

https://developer.android.com/develop/ui/compose/performance/bestpractices?hl=zh-cn
原文档写的很清楚了,我直接 Copy

使用 remember 尽可能减少开销高昂的计算

可组合函数可能会非常频繁地运行,与每个帧的运行频率一样高 动画效果因此,您应当在可组合函数的主体部分中尽可能减少计算。

使用 存储计算结果 [remember](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?hl=zh-cn#remember(kotlin.Function0))。这样,计算只会运行一次, 结果。

例如,下面的代码显示了经过排序的名称列表,但 进行大量排序:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

PerformanceSnippets.kt

每次重组 ContactsList 时,都会对整个联系人列表全部排序 即使该列表没有变化如果用户滚动列表 每当出现新行时,可组合函数都会重组。

如需解决此问题,请在 LazyColumn 外部对列表进行排序,并使用 remember 存储已排序列表:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

PerformanceSnippets.kt

现在,列表只会在 ContactList 首次组合时执行一次排序。如果联系人或比较器发生变化,则系统会重新生成经过排序的列表。否则,可组合函数会继续使用缓存中的已排序列表。

注意:请尽可能将计算代码移到可组合函数的外部。在这种情况下,您可能需要在其他位置(如在 **ViewModel** 中)对列表进行排序,并将已排序列表作为输入提供给可组合函数。

使用延迟布局键

延迟布局可高效地重复使用项,只需重新生成或重组项 。不过,您可以帮助优化延迟布局 重组。

假设某项用户操作会导致项在列表中移动。例如: 假设您显示了按修改时间排序的 上面显示了最近修改过的备注

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

PerformanceSnippets.kt

不过,此代码存在问题。假设底部的备注发生了变化。 它现在是最近修改过的备注,因此会移到列表顶部,而其他备注都会向下移动一个位置。

如果没有您的帮助,Compose 不会意识到未更改的项 moved。相反,Compose 会认为旧的“第 2 项”已被删除,且 为第 3 项、第 4 项以及最下面的一项创建了新的订单。其结果是 Compose 会重组列表中的每一项,即使其中只有一项重组 实际上都发生了变化

此处的解决方案是提供**项键**。为 每一项都可让 Compose 避免不必要的重组。在本例中,Compose 可以确定现在位于位置 3 的物品与以前位于位置 2 的物品相同。 由于该项的数据没有任何变化,因此 Compose 不必 重组。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

PerformanceSnippets.kt

使用 derivedStateOf 限制重组

在组合中使用状态的一个风险是,如果状态发生变化, 界面的重组次数可能会超出您的实际需求。例如: 假设您要显示一个可滚动列表。您可以检查列表的状态, 哪个项是列表中的第一个可见项:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

PerformanceSnippets.kt

问题在于,当用户滚动列表时,listState 会随着用户拖动手指而不断变化。这意味着该列表会不断重组。不过,您不需要频繁重组代码,只需 无需重组,直至新项显示在底部为止。因此,这将完成大量的额外计算,从而导致界面性能较差。

解决方案是使用派生状态。借助派生状态,您可以告知 Compose 哪些状态更改实际上应触发重组。在此示例中 指明您在第一个可见项发生更改时您关心。如果 状态值发生变化时,界面需要重组,但如果用户尚未重组 使新项显示在顶部,则无需重组。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

PerformanceSnippets.kt

尽可能延迟读取

发现性能问题后,延后读取状态会有所帮助。延后读取状态可以确保 Compose 在重组时重新运行尽可能少的代码。例如,如果界面的状态在可组合项树中向上提升,而您在可组合子项中读取状态,则可以将状态封装在 lambda 函数中。这种方式可以确保仅在实际需要时才会执行读取操作。有关参考,请参阅 Jetsnack 中的 示例应用。Jetsnack 实现了类似于工具栏的折叠效果 显示详细信息屏幕要了解这种方法起作用的原因,请参阅这篇博文 Jetpack Compose:调试重组

为了实现这种效果,Title 可组合项需要滚动偏移 以便使用 Modifier 自行偏移。这是 进行优化之前的 Jetsnack 代码:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

PerformanceSnippets.kt

当滚动状态发生变化时,Compose 会使最近的父项失效 重组作用域。在本例中,最接近的范围是 SnackDetail 可组合项。请注意,Box 是内联函数,因此不是重组 范围。因此 Compose 会重组 SnackDetail 以及其中的所有可组合项 SnackDetail。如果您将代码更改为仅读取 使用它,那么您可以减少需要重组的元素数量。

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

PerformanceSnippets.kt

滚动参数现在是一个 lambda。这意味着 Title 仍然可以引用提升的状态,但该值仅在 Title 内部读取,这也是实际需要的。因此,当滚动值发生更改时,最近的重组范围现在是 Title 可组合项 - Compose 不再需要重组整个 Box

这是一项非常重大的改进,但是您还可以做得更好!如果您触发重组只是为了重新布局或重新绘制可组合项,那么您肯定会充满了疑惑。在本例中,您只是更改了 Title 可组合项的偏移量,而此操作可以在布局阶段完成。

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

PerformanceSnippets.kt

以前,该代码使用 [Modifier.offset(x: Dp, y: Dp)](https://developer.android.com/reference/kotlin/androidx/compose/foundation/layout/package-summary?hl=zh-cn#(androidx.compose.ui.Modifier).offset(androidx.compose.ui.unit.Dp,androidx.compose.ui.unit.Dp)),它接受 以参数形式指定偏移量通过切换到 lambda 版本的修饰符, 您可以确保该函数在布局阶段读取滚动状态。因此,当滚动状态发生变化时,Compose 可以完全跳过组合阶段,而直接进入布局阶段。当您将频繁更改的状态变量传递到修饰符中时,应当尽可能使用其 lambda 版本。

下面给出了此方法的另一个示例。此代码尚未优化:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

PerformanceSnippets.kt

在此代码中,Box 的背景颜色会在两种颜色之间快速切换。因此,其状态也会非常频繁地变化。随后,可组合项会在后台修饰符中读取此状态。因此,该 Box 在每一帧上都需要重组,因为其颜色在每一帧中都会发生变化。

如需改进这一点,请使用基于 lambda 的修饰符,在本例中为 [drawBehind](https://developer.android.com/reference/kotlin/androidx/compose/ui/draw/package-summary?hl=zh-cn#(androidx.compose.ui.Modifier).drawBehind(kotlin.Function1))。 这将仅在绘制阶段读取颜色状态。因此 Compose 可以完全跳过组合和布局阶段 - Compose 会直接进入绘制阶段。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

PerformanceSnippets.kt

避免向后写入

Compose 有一项核心假设,即您永远不会向已被读取的状态写入数据。此操作被称为向后写入,它可能会导致无限次地在每一帧上进行重组。

以下可组合项展示了此类错误的示例。

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

PerformanceSnippets.kt

此代码会在可组合项的 前一行。如果运行此代码,您会看到按钮,这会导致重组,则计数器会快速增加, 无限循环,因为 Compose 重组此可组合项,看到状态读取 已过期,所以我们安排了另一次重组。

您完全可以避免向后写入数据,只需避免在组合中写入状态即可。请尽可能在响应事件时写入状态,并采用 lambda 的形式,如上文中的 onClick 示例所示。

其他参考资料:Compose:从重组谈谈页面性能优化思路,狠狠优化一笔许多刚入手Compose的使用者遇到卡顿的时候,可能是不恰当的访 - 掘金

Activity 和 Fragment

2.1 基本概念

组件使用场景优势
Activity作为应用里最关键的界面展示单元,用于打造完整且独立的用户界面屏幕。像应用的启动界面、主要功能模块展示界面等均通过它实现。具备完整生命周期,利于资源管理以及与系统交互;能便捷处理用户操作(如按键按下、屏幕触摸等),也能方便地与其他组件(如服务、广播接收器等)进行交互;Android 系统拥有出色的任务栈管理等针对 Activity 的管理机制,方便应用实现导航和多任务处理。
Fragment在构建可复用的 UI 模块时十分适用,尤其在大屏幕设备(如平板电脑)或复杂多屏应用中表现突出。例如新闻应用里,新闻列表 Fragment 能在不同布局(单栏、双栏布局)中复用,也可在多个相关 Activity 中共享。极大提升了 UI 的复用性与灵活性,可依据不同设备配置和屏幕尺寸,灵活组合与替换以达成不同布局效果;Fragment 拥有独立逻辑和视图,便于团队开发与代码维护,不同开发人员可分别负责不同 Fragment 的开发工作 。
Compose适合快速搭建现代化、动态的 UI 界面,对于需要频繁更新 UI 或处理复杂状态管理的应用部分效果显著。比如在实时数据更新(如股票行情应用)或复杂交互(如手势操作驱动 UI 变化)的应用场景中能发挥优势。代码更为简洁直观,采用声明式方式构建 UI,降低了传统视图体系中视图操作和状态管理的复杂程度;自动重组机制极大简化 UI 更新流程,提高开发效率;提供丰富的布局和组件库,助力开发者快速实现各类 UI 设计。

2.2 生命周期

生命周期方法调用时机
onCreate()在Activity第一次被创建时调用。通常用于进行初始化操作,如设置布局(通过setContentViewsetContent)、初始化视图组件、绑定数据等。
onStart()在Activity即将对用户可见时调用。此时Activity已经可见,但还没有获取焦点,用户还不能与之交互。
onResume()在Activity获取焦点,用户可以开始与之交互时调用。此时Activity处于活动状态,位于前台。
onPause()当系统准备启动或恢复另一个 Activity 时调用。此时当前 Activity 失去焦点,但仍然部分可见。例如,当一个透明或者半透明的 Activity 启动时,原 Activity 就会进入onPause状态。这是一个比较关键的回调,通常用于暂停一些资源消耗大的操作,如暂停动画、停止视频播放等,并且可以保存一些关键数据,防止用户离开该页面后数据丢失或资源浪费。
class MainActivity : AppCompatActivity() {
    override fun onPause() {
        super.onPause()
        // 暂停视频播放
        videoPlayer.pause()
        // 保存当前页面的编辑状态
        val editTextContent = editText.text.toString()
        savedState.editTextContent = editTextContent
    }
}

|
|onStop() |当 Activity 完全不可见时调用。比如用户打开了一个新的 Activity,并且这个新 Activity 完全覆盖了当前 Activity,或者用户按下了 “Home” 键将应用退到后台,当前 Activity 就会进入onStop状态。这个回调可以用于释放一些只有在 Activity 可见时才需要的资源,如停止传感器的监听等。 |

class SensorActivity : AppCompatActivity() {
    private val sensorManager: SensorManager by lazy {
        getSystemService(Context.SENSOR_SERVICE) as SensorManager
    }
    private val accelerometer: Sensor by lazy {
        sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
    }
    private val sensorEventListener = object : SensorEventListener {
        // 处理传感器事件的方法
    }
    override fun onStart() {
        super.onStart()
        sensorManager.registerListener(sensorEventListener, accelerometer, SensorManager.SENSOR_DELAY_NORMAL)
    }
    override fun onStop() {
        super.onStop()
        sensorManager.unregisterListener(sensorEventListener)
    }
}

|
|onDestroy() |在Activity被销毁之前调用。用于释放所有占用的资源,如关闭数据库连接、停止服务、注销广播接收器等。 | |
|onRestart() |在Activity被停止后,再次启动时调用。它会在onStart()之前被触发,通常用于在Activity重新启动时恢复之前保存的状态。 | |

Activty 仅关注于自身,如果需要全局的生命周期监听,可能需要使用 ProcessLifecycleOwner。它对于简单地判断应用整体是处于前台还是后台的场景非常方便。例如,在一些需要根据应用前后台状态来调整服务行为的应用中,像应用进入后台时暂停数据同步服务,进入前台时恢复数据同步服务,使用ProcessLifecycleOwner可以快速实现。

当应用架构基于现代的 Jetpack 组件(如 ViewModel、LiveData 等),并且已经在使用lifecycle - runtime - ktx库来管理组件生命周期时,ProcessLifecycleOwner能够很好地集成到现有的架构中,以一种简洁的方式提供应用生命周期感知。

首先我们需要先注册(在未注册 lifecycle - runtime - ktx 的情况下):

dependencies {
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0'}
}

我们对比两者的生命周期:

对比维度Activity 生命周期ProcessLifecycleOwner 生命周期
关注对象单个Activity从创建到销毁过程中的不同状态变化,比如是否可见、是否可交互等。整个应用进程处于前台还是后台的宏观状态。
关键状态及回调onCreate:首次创建时调用,用于初始化操作。onStart:即将对用户可见时调用。onResume:获取焦点,可与用户交互时调用。onPause:失去焦点但可能部分可见时调用。onStop:完全不可见时调用。onDestroy:被销毁前调用,用于释放资源。onRestart:停止后再次启动时调用。ON_START:当应用中有一个Activity进入onStart状态,意味着应用进入前台,触发此事件。ON_STOP:当应用中的最后一个Activity进入onStop状态,意味着应用进入后台,触发此事件。
触发条件由Activity自身在屏幕上的可见性、交互性变化等因素触发,例如启动、切换、按“Home”键等操作导致相应状态改变时触发对应回调。通过监测应用内所有Activity的整体状态来触发,关注是否还有Activity处于可见状态(ON_START)或者所有Activity都不可见了(ON_STOP)。
使用场景用于管理单个Activity内的资源初始化、释放,处理Activity自身相关的UI更新、交互逻辑等。例如在onPause暂停视频播放,在onResume恢复播放。用于处理应用整体进入前台或后台时的通用逻辑,比如应用进入后台暂停数据同步、传感器监听等服务,进入前台时恢复这些操作。
代码示例(Kotlin) - 简单示意
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 初始化布局等操作
    }
    override fun onStart() {
        super.onStart()
        // 准备显示相关操作
    }
    override fun onResume() {
        super.onResume()
        // 获取焦点,可交互操作
    }
    override fun onPause() {
        super.onPause()
        // 失去焦点,暂停相关操作
    }
    override fun onStop() {
        super.onStop()
        // 完全不可见,释放部分资源
    }
    override fun onDestroy() {
        super.onDestroy()
        // 释放所有资源
    }
    override fun onRestart() {
        super.onRestart()
        // 重新启动相关操作
    }
}

|

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 使用代码块形式添加生命周期观察者
        ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onStop(owner: LifecycleOwner) {
                super.onStop(owner)
                Log.d("MyApp", "App moved to background")
            }
            override fun onStart(owner: LifecycleOwner) {
                super.onStart(owner)
                Log.d("MyApp", "App moved to foreground")
            }
        })
    }
}

|

2.3 路由与路由数据传递

2.3.1 Intent 机制与数据传递

Parcel 是 Android 中用于序列化和反序列化对象的一种机制,在传递对象(如通过 Intent 传递)时,对象需要被拆解成可以存储和传输的形式(序列化),在接收端再重新组装成对象(反序列化)。

我们创建一个表示用户信息的 User 数据类,并让它实现 Parcelable 接口,以便可以通过 Intent 传递。

import android.os.Parcel
import android.os.Parcelable

data class User(
    val id: String,
    val name: String,
    val age: Int
) : Parcelable {
    // 这个构造函数就是在接收端从 Parcel 中读取数据来重新创建 User 对象。
    constructor(parcel: Parcel) : this(
        parcel.readString()!!,
        parcel.readString()!!,
        parcel.readInt()
    )

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeString(id)
        parcel.writeString(name)
        parcel.writeInt(age)
    }

    override fun describeContents(): Int {
        return 0
    }

    companion object CREATOR : Parcelable.Creator<User> {
        override fun createFromParcel(parcel: Parcel): User {
            return User(parcel)
        }

        override fun newArray(size: Int): Array<User?> {
            return arrayOfNulls(size)
        }
    }
}
  • writeToParcel 方法:
    • override fun writeToParcel(parcel: Parcel, flags: Int) 负责将 User 对象的各个属性写入到 Parcel 中,以便进行传递。在这个方法里,按照顺序分别调用了 parcel.writeString(id)parcel.writeString(name)parcel.writeInt(age),也就是把 idnameage 这三个属性的值依次写入 Parcel,确保在接收端能够按照同样的顺序正确读取并恢复对象。
  • CREATOR 伴生对象及相关方法:
    • companion object CREATOR : Parcelable.Creator<User>Parcelable 接口要求实现的一个伴生对象,用于创建 User 类的实例。其中包含两个方法:
      • override fun createFromParcel(parcel: Parcel): User 方法会在反序列化时被调用,它的任务就是利用前面提到的从 Parcel 中读取数据的构造函数(constructor(parcel: Parcel))来创建一个新的 User 对象,也就是返回 User(parcel)
      • override fun newArray(size: Int): Array<User?> 方法用于创建一个指定大小的 User 数组,在一些涉及到数组形式的 Parcelable 对象传递场景下会用到,这里简单地返回了一个包含 null 值的指定大小的数组,即 arrayOfNulls(size)

定义两个 Activity,一个是主页面,另一个是用户详情页。主页面包含一个按钮,点击按钮后通过路由导航到用户详情页面,并传递用户数据。

主页面 Activity 使用 Compose 来构建 UI。

class MainActivity : androidx.activity.ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    // 省略
                ) {
<mark style="background-color: #FFE928">                    // 创建路由 Controller</mark><mark style="background-color: #FFE928">                    val navController = rememberNavController()</mark><mark style="background-color: #FFE928">                    // 创建 User 对象,在 MainScreen 中跳转时传入 Navigator</mark><mark style="background-color: #FFE928">                    val user = User("1", "John Doe", 30)</mark><mark style="background-color: #FFE928">                    MainScreen(navController, user)</mark><mark style="background-color: #FFE928">                    SetupNavigation(navController)</mark>
                }
            }
        }
    }
}

@Composable
fun MainScreen(navController: NavController, user: User) {
    Column {
        Text(text = "主页面")
        Button(onClick = {
<mark style="background-color: #FFE928">            // 跳转其它页面</mark><mark style="background-color: #FFE928">            navController.navigate("user_detail/$user")</mark>
        }) {
            Text(text = "查看用户详情")
        }
    }
}

@Composable
fun SetupNavigation(navController: NavController) {
    NavHost(
        navController = navController,
        startDestination = "main"
    ) {
        composable("main") {
            val navBackStackEntry = it.currentBackStackEntryAsState().value
            val context = LocalContext.current
            val user = navBackStackEntry?.arguments?.getParcelable<User>("user")
            MainScreen(navController, user?: User("default", "Default User", 0))
        }
        composable(
            route = "user_detail/{user}",
            arguments = listOf(navArgument("user") { type = NavType.ParcelableType(User::class.java) })
        ) { backStackEntry ->
            val receivedUser = backStackEntry.arguments?.getParcelable<User>("user")
            UserDetailScreen(receivedUser)
        }
    }
}

MainActivitySetupNavigation 这个可组合函数中,通过 NavHost 来定义整个应用的导航结构。

  • NavHost 函数接受 navController(用于控制导航操作)和 startDestination(指定起始的导航页面)作为参数。这里起始页面设置为 "main"
  • NavHost 内部,通过 composable 函数来定义各个具体的可导航页面。
    • 对于 "main" 页面:
      • 首先通过 it.currentBackStackEntryAsState().value 获取当前导航栈的状态信息,进而可以获取传递过来的参数(在这个例子中可能是 User 对象)。
      • 然后将获取到的 User 对象(或者默认的用户对象,如果没有传递过来有效的对象)传递给 MainScreen 可组合函数来展示主页面的 UI 内容。
    • 对于 "user_detail/{user}" 页面:
      • 定义了路由参数 "user",并且指定其类型为 NavType.ParcelableType(User::class.java),这样就能接收通过路由传递过来的 User 类型的对象。
      • 在页面的可组合函数内(通过 backStackEntry 获取参数),将接收到的 User 对象传递给 UserDetailScreen 可组合函数来展示用户详情页面的 UI 内容。

之后我们再定义一个用户详情 Activity,同样使用 Compose 构建 UI,用于展示接收到的用户信息,并在页面重建(如屏幕旋转等情况)时恢复数据展示。

class UserDetailActivity : androidx.activity.ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    // 省略
                ) {
<mark style="background-color: #FAF390">                    // 从 savedInstanceState  或者 Intent 中获取 User,并加载 UI</mark><mark style="background-color: #FAF390">                    val user = if (savedInstanceState == null) {</mark><mark style="background-color: #FAF390">                        intent.getParcelableExtra("user")</mark><mark style="background-color: #FAF390">                    } else {</mark><mark style="background-color: #FAF390">                        savedInstanceState.getParcelable("user")</mark><mark style="background-color: #FAF390">                    }</mark><mark style="background-color: #FAF390">                    UserDetailScreen(user)</mark>
                }
            }
        }
    }

    // 使用 onSaveInstanceState 进行数据恢复
    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        val user = intent.getParcelableExtra("user")
        outState.putParcelable("user", user)
    }
}

@Composable
fun UserDetailScreen(user: User?) {
    var currentUser by remember { mutableStateOf(user) }
    LaunchedEffect(key1 = user) {
        currentUser = user
    }
    Column {
        Text(text = "用户详情页面")
        currentUser?.let {
            Text(text = "姓名: ${it.name}")
            Text(text = "年龄: ${it.age}")
        }
    }
}

给到的例子中充分体现了「数据传递」和「数据恢复」两大功能:

  • 数据传递:从主页面传递 User 对象到用户详情页面时,在 MainScreen 中通过 navController.navigate 把对象放在路由路径里传递,在 SetupNavigation 中针对 "user_detail/{user}" 路由定义的地方,正确地解析并获取传递过来的 User 对象,然后传递给 UserDetailScreen 用于展示。
  • 数据恢复:在 UserDetailActivityonSaveInstanceState 方法中,依然将接收到的 User 对象保存到 outState 中,以便在页面重建(如屏幕旋转等情况)时能恢复数据。在 UserDetailScreen 可组合函数里,使用 LaunchedEffectremember 等 Compose 特性来处理数据变化,确保当接收到新的用户数据(比如页面重建后恢复的数据)时能正确更新 UI 展示,以此提供良好的用户体验。

2.3.2 XML、Compose Navigation DSL 管理路由

部分老旧项目中仍然使用 XML 管理路由。例如上个 case 就可以像这样使用路由:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res/app"
    xmlns:tools="http://schemas.android.com/apk/res/tools"
    app:startDestination="main">

    <composable
        android:id="main"
        android:name="com.example.myapplication.MainScreen"
        app:route="main">
    </composable>

    <composable
        android:id="user_detail"
        android:name="com.example.myapplication.UserDetailScreen"
        app:route="user_detail/{user}"
        app:arguments="@array/user_detail_arguments">
    </composable>

    <action
        android:id="action_main_to_user_detail"
        app:destination="@id/user_detail"
        app:popUpTo="@id/main"
        app:popUpToInclusive="false">
    </action>
    <!-- 其他相关配置 -->
</navigation>
  • 传统项目和大型团队协作场景:在一些已经存在的大型Android项目中,XML路由管理仍然被广泛使用。这些项目可能有较长的开发周期和复杂的团队协作结构。XML提供了一种清晰、易于理解的方式来定义整个应用的导航架构,不同的开发人员可以通过查看XML文件快速了解页面之间的跳转关系。例如,一个拥有多个模块和众多Activity的金融类应用,使用XML来定义路由可以使得模块之间的导航逻辑一目了然。
  • 与 Android 原生组件集成场景:当应用需要深度集成Android原生的组件(如通知栏跳转、系统快捷方式跳转等)时,XML路由管理可以更好地与系统原生的Intent机制相结合。例如,通过在XML中定义好的路由路径,当用户点击通知栏中的消息时,系统可以根据XML中的配置准确地跳转到应用内对应的页面。

Compose Navigation DSL 是逐渐兴起的替代方案或补充方式。

  • 代码驱动的路由管理(如Compose Navigation DSL):在使用Jetpack Compose构建UI的项目中,Compose Navigation DSL提供了一种代码驱动的路由管理方式。开发者可以通过编写Kotlin代码来定义导航图,这种方式更加灵活,并且与Compose的编程风格紧密结合。例如,在一个完全基于Compose构建的小型工具类应用中,通过代码定义路由可以使导航逻辑与UI组件的构建更加紧密地集成在一起。
  • 动态路由管理(基于运行时条件):有些应用需要根据用户权限、设备状态或者业务逻辑动态地生成路由路径。在这种情况下,代码中动态构建路由的方式就显得更加合适。比如,一个具有用户角色权限系统的企业级应用,不同角色的用户登录后,根据其权限动态生成不同的导航菜单和路由路径,这种情况下通过代码动态控制路由比固定的XML路由更加灵活。

文章作者: 海星来来
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 海星来来 !
  目录