Android | Compose状态管理

简介: Android | Compose状态管理

前言


应用中的状态指的是可以随时间变化的任何值。这个定义非常广泛,例如从数据库到类的变量,页面上显示的提示信息等。


状态和组合


由于 Compose 是声明式工具集,因此更新它的唯一方法是通新参数调用同一可组合项。这些参数是界面状态表现形式。每当状态更新时,都会发生重组。


可组合项中的状态


可组合函数可以使用 remember 可组合项记住单个对象。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组的期间返回存储的值。remember 既可以用于存储可变对象,又可用于存储不可变对象。


注意:remember 会将对象状态存储在组合中,当调用 remember 的可组合项从组合中移除后,它会忘记该对象


mutableStateOf 会创建可观察的 MutableState ,后者是与 Compose 运行时集成的可观察类型。


interface MutableState<T> : State<T> {
    override var value: T
}


value 如果有任何更改,系统会重新读取 value 的所有可组合函数。对于 ExpandingCard ,每当 expanded 发生变化时,都会导致重组 EXPANDINGcARD。


在可组合项中声明 MutableState 对象的方法有三种:


val mutableState = remember { mutableStateOf(default) }

var value by remember { mutableStateOf(default) }

val (value, setValue) = remember { mutableStateOf(default) }

例子:


@Composable
fun HomeCompos() {
    var text by remember { mutableStateOf("") }
    SetScaffold(title = "首页") {
        Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Top) {
            Button(onClick = {
                text = "hello word"
            }) {
                if (text.isEmpty()) {
                    Text(text = "hello", fontSize = 50.sp)
                } else {
                    Text(text = text, fontSize = 50.sp)
                }
            }
        }
    }
}


虽然 remember 可以帮助在组合后保持状态,但不会帮助在配置更改后保持状态。为此,你必须使用 rememberSaveable 来保存配置改变后的状态,例如屏幕旋转。


其他受支持的状态类型


Jetpack Compose 并不要求必须使用 MutableState 存储状态。事实上也支持其他的类型,但是在 Compsoe 读取其他可观察类型之前,需要将其转为 State ,以便 Compose 可以在状态发生改变的时候进行重组。


Compose 附带一下可以根据 Android 应用中常见的观察类型创建 State 的函数:


LiveData


fun HomeCompos(navController: NavHostController) {
    var text by remember { mutableStateOf("") }
    val liveData = MutableLiveData("").observeAsState()
    val liveData = MutableLiveData("")
    val liveDataState = liveData.observeAsState()
    SetScaffold(title = "首页") {
        Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Top) {
            Button(onClick = {
                liveData.value = "Hello 345"
            }) {
                if (liveDataState.value!!.isEmpty()) {
                    Text(text = "hello", fontSize = 50.sp)
                } else {
                    Text(text = liveDataState.value!!, fontSize = 50.sp)
                }
            }
        }
    }
}


Flow


@Composable
fun HomeCompos(navController: NavHostController) {
    val flow = flow<String> {
        emit("Hello ")
        delay(2000)
        emit("Hello 345")
    }
    val flowState = flow.collectAsState("345")
    SetScaffold(title = "首页") {
        Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Top) {
            Button(onClick = {}) {
                if (flowState.value.isEmpty()) {
                    Text(text = "hello", fontSize = 50.sp)
                } else {
                    Text(text = flowState.value, fontSize = 50.sp)
                }
            }
        }
    }
}


RxJava2


可查看


有状态和无状态


使用 remember 存储对象的可组合项会创建内部状态,使该可组合项有状态。例如上面最开始的例子 HomeCompos 就是一个有状态这组合项的示例。因为他在内部会保持和修改 text 状态。在调用方不需要控制状态,并且不必自行管理便可使用状态的情况下,有状态会非常好用,但是有内部状态的组合往往不易重复使用,也更难测试。


无状态可组合项是指不保持任何状态的可组合项。实现的一种简单的方式是使用 状态提升。


在开发可重复使用组合项时,你通常需要同时提供一组有状态的版本和无状态的版本。有状态版本对于不关心状态来说很方便,而无状态版本对于都需要控制或提升状态的调用来说是必要的。


状态提升


Compose 中的状态提升是一种将状态移到可组合项调用方,使得可组合项无状态的模式。Compose 中常规的状态提升模式是将状态变量替换为两个参数:


value:T :要显示的当前值

onValueChange:(T)->Unit :请求更改值的时间

不过,并不局限于 onValueChange ,如果更具体的事件适合组合项,就可以使用更合适的事件。


以这种方式提升的状态需要一些重要的属性:


单一可信来源:不要进行复制状态,确保只有一个可信来源。这样有助于避免 bug

封装:只有状态可组合项能够修改状态,这完全是内部操作。

可共享:可与多个可组合项共享提升的状态。如果另一个可组合项中执行 name 的操作,可以通过变量提升来做到这一点。

可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或者修改事件

解耦:无状态的可组合项中提升的状态可以放在任何位置,例如放入 viewModle 中。

下面例子中,使用了状态提升:


@Composable
fun HomeCompose() {
    var text by remember { mutableStateOf("") }
    Log.e("---345--->", "11111");
    Content(text = text, onTextChange = { text = it })
}
@Composable
fun Content(text: String, onTextChange: (String) -> Unit) {
    SetScaffold(title = "首页") {
        Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Top) {
            Button(onClick = {
                onTextChange.invoke("Hello 345")
            }) {
                if (text.isEmpty()) {
                    Text(text = "hello", fontSize = 50.sp)
                } else {
                    Text(text = text, fontSize = 50.sp)
                }
            }
        }
    }
}


通过从 Content 中提升出状态,更容易推断该组合项,在不同的情况下使用它,以及进行测试。


HelloContent 与状态的存储方式解耦,这意味着如果你更换或者修改 HomeCompose ,不必修改 Content 的实现方式。


状态下降,时间上升 这种模式简称为 单向数据流。这种情况下,状态会从 HomeCompose 下降到 Content 中,事件会从 Content 中上升到 HomeCompose 中。通过遵守单向数据流,我们可以将页面中显示状态的可组合项与应用中存储和更改的部分解耦。


注意:提升状态时,有三条规则可以帮助你弄清楚状态应去向何处


1,状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项。


2,状态应至少提升到他可以发生变化(写入)的最高级别


3,如果两种状态发生变化以响应相同的事件,他们应一起提升


恢复状态


在 activity 重新创建后,可以使用 rememberSaveable 恢复界面状态。rememberSaveable 可以在重组后保持状态,此外,也可以在重新创建 activity 和进程后保持状态


存储状态的方式


添加到 Bundle 的所有数据类型都会被保存。如果要保存无法添加到 Bundle 的内容,您有以下集中选择


Parcelize


使用 @Parceize 注解。对象就会变为可打包状态,并且可以捆绑,如下:


@Parcelize
data class City(val name: String, val country: String) : Parcelable
@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}


MapSaver


如果有某种原因不能使用 @Parcelize ,就可以使用 mapSaver 定义自己的规则,如下:


data class City(val name: String, val country: String)
val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}
@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}


ListSaver


为了避免需要映射定义键,也可以使用 listSaver 并将其索引用作键:


data class City(val name: String, val country: String)
val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)
@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}


管理状态


你可以通过组合函数本身管理简单的状态提升。但是随着状态数量的增加,或者组合函数中出现要执行的逻辑,最好将逻辑和状态事务委托给其他类(状态容器)。


状态容器用于管理可组合项的逻辑和状态,状态容器也被称为 “提升的状态对象”


状态容器的大小不等,具体取决于所管理界面元素的范围(从底部应用栏等单个微件到整个屏幕)。状态容器可以组合使用,也就是说,可以将某个状态容器集成到其他状态容器中,尤其是在汇总状态时。


Compose 中可以使用多种不同的方式来管理状态,如:


可组合项:用于管理简单的界面元素状态

状态容器:用于管理复杂页面的元素状态,且用于界面元素的状态和界面逻辑。

架构组件 ViewModel:一种特殊的状态容器类型,用于提供对业务逻辑已经屏幕界面状态的访问权限

下图所示为 compose 状态管理所涉及的各实体之间的关系:


可组合项可以依赖 0个或者多个状态容器,可以使普通对象,或者是viewmodel ,或者二者皆有

如果跑普通的状态容器需要访问业务逻辑或者屏幕状态,则可能需要依赖于 ViewModel

ViewModel 依赖于业务层或者数据层


状态和逻辑的类型


在 android 应用中,需要考虑不同的类型状态


界面元素状态:是界面元素的提升状态,例如,ScaffoldState 用于处理 Scaffold 可组合项的状态

屏幕或界面状态:是屏幕需要显示的内容,例如 UserState 类中包含了用户姓名,手机号码等信息。该状态通常会与其他层关联,原因是其包含应用数据。

界面行为逻辑或界面逻辑:与如何在屏幕上显示状态变化相关,例如,导航逻辑决定接下来显示那个屏幕。界面逻辑应始终位于组合中。

业务逻辑:决定如何处理状态变化,例如存储用户偏好设置,该逻辑通常位于数据层,绝对不会位于界面层


将可组合项作为信任来源


@Composable
fun Content() {
    val scaffoldState = rememberScaffoldState()
    val coroutineScope = rememberCoroutineScope()
    Scaffold(scaffoldState = scaffoldState) {
        Button(onClick = {
            coroutineScope.launch {
                scaffoldState.snackbarHostState.showSnackbar("hello")
            }
        }) {
            Text(text = "按钮")
        }
    }
}


scaffoldState 中包含了 Scaffold 的可变属性,例如侧边栏的状态,以及显示提示框等,ScaffoldState 如下所示:


@Stable
class ScaffoldState(
    val drawerState: DrawerState,
    val snackbarHostState: SnackbarHostState
)


可以清楚的看到里面保存了两种状态,一个侧边栏的,一个就是提示框。通过 rememberScaffoldState 获取后,就会对状态进行缓存,以防止下次重新组合的时候出现问题。


将状态容器作为可信来源


上面例子中的状态容器 ScaffoldState 是系统提供的,只能保存相对应的状态,如果可组合项包含了多个界面元素状态页面逻辑非常复杂的时候,就应该使用自定义的状态容器了。这样做更容易进行测试,还降低了可组合项的复杂性。


状态容器是在可组合中创建和保存的普通类。状态容器需要遵循 可组合项的生命周期,因此可以此采用 Compose 依赖项。


例如,创建一个状态容器来管理可组合元素的状态,如下:


@Composable
fun HomeCompose() {
    val userState = rememberUserState()
    Log.e("---345--->", "${userState.value.name}  ${userState.value.age}");
    Scaffold() {
        Column() {
            Spacer(modifier = Modifier.padding(top = 50.dp))
            Button(onClick = {
                userState.value = MyUserState("张三", 26)
            }) {
                Text(text = "按钮")
            }
            Spacer(modifier = Modifier.padding(top = 100.dp))
            Text(text = "姓名 ${userState.value.name}")
            Text(text = "姓名 ${userState.value.age}")
        }
    }
}
@Composable
fun rememberUserState(myUserState: MutableState<MyUserState> = mutableStateOf(MyUserState())) =
    remember() {
        mutableStateOf(MyUserState(myUserState.value.name, myUserState.value.age))
    }
class MyUserState(
    var name: String = "345",
    var age: Int = 20
) {
    var sex: String = "男"
    fun getUserId(): Int {
        return 0
    }
}


上面代码中创建了一个状态容器 MyUserState 用来保存用户信息,然后通过 remember 对状态进行保存,当 HomeCompose 重组的时候,就可以获取到之前的数据,例如在 onClick 中修改了 value 的值,这就会导致 HomeCompose 重组,但是获取到的值是已经保存的值了。


需要注意的是 remember 有个参数 key,这个参数默认不需要传入,key 的作用是用来判断重复性的,例如重组的时候这个key和上一次的可以不相同,那么就不会获取之前保存的值,而是直接创建一个新的状态。


还有一点须要注意:rember 的恢复只限于当前函数的重组,例如只有 HomeCompose 进行重组,状态才可以恢复。如果是 HomeCompose 父作用域进行重组,那么状态也是会没有的。


将ViewModel 作为可信任来源


如果普通的状态容器类负责界面逻辑以及界面元素的状态,则 ViewModel 是一种特殊的状态容器类型。其负责:


提供对应用的业务逻辑访问权限,该逻辑通常位于层次结构的其它层,例如业务层和数据层。

准备要在特定的屏幕上呈现的应用数据,这些数据会成为屏幕或者页面的状态。

ViewModel 的生命周比组合长 ,原因是配置发生改变后任然有效,也可以遵守目的地或者导航图的生命周期(Navgiation库)。ViewMode 的生命周期较长,因此不应该保留对绑定到组合生命周期状态的长期引用,否则可能会导致内存泄漏。


一般推荐屏幕级别可组合项来配合 ViewModel 使用。


以下是在屏幕级别的组合项中使用示例:


@Composable
fun HomeDetail(
    viewModel: HomeViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
) {
    Log.e("---345--->", viewModel.state.name);
    Log.e("---345--->", viewModel.toString());
    Scaffold() {
        Column() {
            Spacer(modifier = Modifier.padding(top = 50.dp))
            Button(onClick = {
                viewModel.state = MyUserState("张三", 50)
            }) {
                Text(text = "按钮")
            }
            Spacer(modifier = Modifier.padding(top = 100.dp))
            Text(text = "姓名 ${viewModel.state.name}")
            Text(text = "姓名 ${viewModel.state.age}")
        }
    }
}
class HomeViewModel : ViewModel() {
    var state by mutableStateOf(MyUserState())
}
class MyUserState(
    var name: String = "345",
    var age: Int = 20
) {
    var sex: String = "男"
    fun getUserId(): Int {
        return 0
    }
}


上面的 viewmodel 就是在屏幕级别的组合项中使用,HomeDetail 该组合项是通过 navigation 跳转过去的,所以当退出该页面的时候 viewmodel 会被释放。每次进入都会创建新的 viewModel。


另外,如果 ViewModle 在非顶级的组合中使用时,即使该组合以及父组合重建,该 ViewMode 也不重建,因为 VIewModel 的生命周期大于可组合项,所以这种情况 ViewModel 尽可能的不要依赖可组合项,否则可能会出现内存泄漏。


remember 源码浅析


remember


remember 有多个重载,增加了参数 key。 如果 key 等于前一个组合的 key,就返回上一次的值,否则返回一个新值


@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)
/**
 * 如果key1等于前一个组合,则记住计算返回的值,否则通过调用计算生成并记住一个新值
 */
@Composable
inline fun <T> remember(
    key1: Any?,
    calculation: @DisallowComposableCalls () -> T
): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}
/**
 * 如果 [key1] 和 [key2] 等于之前的组合,则记住 [calculation] 返回的值,否则通过调用 [calculation] 生成并记住一个新值。
 */
@Composable
inline fun <T> remember(
    key1: Any?,
    key2: Any?,
    calculation: @DisallowComposableCalls () -> T
): T {
    return currentComposer.cache(
        currentComposer.changed(key1) or currentComposer.changed(key2),
        calculation
    )
}


Composer.cache


获取之前的 value,如果等于之前的值 invalid 就是 false,否则为 true。


@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        //如果 key 不等于之前的值 或者之前没有缓存
        if (invalid || it === Composer.Empty) {
            // 缓存 value ,并返回
            val value = block()
            updateRememberedValue(value)
            value
        } else it //返回之前缓存的 value
    } as T
}


Composer.updateRememberedValue


缓存 value,这里最终会调到 updateValue 中,该方法在 Composer 的实现类 ComposerImpl 中:



//保存 value
fun update(value: Any?): Any? {
    val result = skip()
    set(value)
    return result
}
fun skip(): Any? {
    if (insertCount > 0) {
        insertSlots(1, parent)
    }
    return slots[dataIndexToDataAddress(currentSlot++)]
}
fun set(value: Any?) {
    runtimeCheck(currentSlot <= currentSlotEnd) {
        "Writing to an invalid slot"
    }
    slots[dataIndexToDataAddress(currentSlot - 1)] = value
}


Compose.rememberedValue


rememberedValue 最终会调用拿到 nextSlot 中:


internal fun nextSlot(): Any? = if (inserting) { //如果正在将新的节点插入到视图数中 ,染回 Emepty
    validateNodeNotExpected()
    Composer.Empty
} else reader.next().let { //获取 value
    //如果重用,返回 Empty,否则 返回 value
    if (reusing) Composer.Empty else it
}
fun next(): Any? {
    if (emptyCount > 0 || currentSlot >= currentSlotEnd) return Composer.Empty
    return slots[currentSlot++]
}


slots 是一个树组,currentSlot 表示状态在数组中的索引。


流程图


总结一下


remember 用来记录当前组合项的状态,当重组的时候,可以拿出之前的数据进行使用。但是需要注意的是重组如果是从父组合项开始的,那么状态不会保留。


mutableStateOf 创建可观察的 MutableState ,当 value 发生变化后,Compose 就会重组使用 value 的组合项。


viewModel 适合在顶级的作用域中使用,例如在 activity 的 最上层可组合函数,以及 navgation 跳转页面中的组合函数中使用。不推荐在普通的组合函数中使用,可能会造成内存泄漏。


管理状态可以分为三种:


如果状态和逻辑非常简单,就可以使用界面元素状态,例如 ScaffoldState 等。


自定义状态容器来保存你所需要的状态,然后在通过 remember 进行保存就行了。这种情况适合于屏幕级别组合项的可组合函数,否则当父组合项重组的时候,自己的数据也会丢失。


使用 Viewmodel 保存状态,ViewModel 的生命周期比组合长,ViewModel 可以遵循 Activity 或者 Fragment 的生命周期,或者是 Navigation 的生命周期。


所以 ViewModel 作用于不是屏幕级别组合项的时候,不应该保留绑定到组合生命周期状态的长期引用,否则可能会导致内存泄漏。


相关文章
|
5月前
|
存储 缓存 Android开发
安卓Jetpack Compose+Kotlin, 使用ExoPlayer播放多个【远程url】音频,搭配Okhttp库进行下载和缓存,播放完随机播放下一首
这是一个Kotlin项目,使用Jetpack Compose和ExoPlayer框架开发Android应用,功能是播放远程URL音频列表。应用会检查本地缓存,如果文件存在且大小与远程文件一致则使用缓存,否则下载文件并播放。播放完成后或遇到异常,会随机播放下一首音频,并在播放前随机设置播放速度(0.9到1.2倍速)。代码包括ViewModel,负责音频管理和播放逻辑,以及UI层,包含播放和停止按钮。
|
5月前
|
存储 数据库 Android开发
安卓Jetpack Compose+Kotlin,支持从本地添加音频文件到播放列表,支持删除,使用ExoPlayer播放音乐
为了在UI界面添加用于添加和删除本地音乐文件的按钮,以及相关的播放功能,你需要实现以下几个步骤: 1. **集成用户选择本地音乐**:允许用户从设备中选择音乐文件。 2. **创建UI按钮**:在界面中创建添加和删除按钮。 3. **数据库功能**:使用Room数据库来存储音频文件信息。 4. **更新ViewModel**:处理添加、删除和播放音频文件的逻辑。 5. **UI实现**:在UI层支持添加、删除音乐以及播放功能。
|
2月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
54 4
|
4月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
5月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
5月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
5月前
|
缓存 Android开发 Kotlin
【安卓app开发】kotlin Jetpack Compose框架 | 先用OKhttp下载远程音频文件再使用ExoPlayer播放
使用 Kotlin 的 Jetpack Compose 开发安卓应用时,可以结合 OkHttp 下载远程音频文件和 ExoPlayer 进行播放。在 `build.gradle` 添加相关依赖后,示例代码展示了如何下载音频并用 ExoPlayer 播放。代码包括添加依赖、下载文件、播放文件及简单的 Compose UI。注意,示例未包含完整错误处理和资源释放,实际应用需补充这些内容。
|
5月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
5月前
|
存储 Android开发 Kotlin
开发安卓app OKhttp下载后使用MediaPlayer播放
在Android Jetpack Compose应用程序中,要使用OkHttp下载远程音频文件并在本地播放,你需要完成以下几个步骤: 1. **添加依赖**:确保`build.gradle`文件包含OkHttp和Jetpack Compose的相关依赖。 2. **下载逻辑**:创建一个`suspend`函数,使用OkHttp发起网络请求下载音频文件到本地。 3. **播放逻辑**:利用`MediaPlayer`管理音频播放状态。 4. **Compose UI**:构建用户界面,包含下载和播放音频的按钮。
|
6月前
|
算法 Android开发
Compose - Text 详解,2024年Android社招面试题精选
Compose - Text 详解,2024年Android社招面试题精选
Compose - Text 详解,2024年Android社招面试题精选