稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

轻松解决Jetpack Compose中的一些痛点问题

暑去秋来,金桂飘香,不知不觉中我们已经练完了『降Compose十八掌』,相信通过这一系列文章能够对Jetpack Compose有足够的理解,并能在实际项目中进行运用。今天将继续Compose之旅,总结一下Compose使用过程中经常会遇到的一些痛点问题,并学会如何优雅的解决这些问题。

定义slot时要注明布局作用域

先来看一个比较常规的问题,Compose开发过程中,非常鼓励开发者把可以复用的部分抽象成为一个函数,然后接收一个尾部lambda作为参数进行差异化的定制。这种范式叫做slot模式,slot模式的好处在于能够大大加强代码复用,开发者在构建UI的时候,像搭积木那样把一个一个的slot叠在一起。Compose自己的API中都大量的采用了这种模式。

为了让slot更加的通用,我们需要明确传入的lambda与slot之间的约定,这就要求我们对lamdba的类型进行严格的限制。

首先要添加注解@Composable,这个是显而易见的,因为slot是为了绘制一些自定义UI元素而准备的,所以肯定是要加上@Composable,否则在lambda中无法写UI,因为非Composable不能调用Compose的方法。

另外,不是那么明显的就是这个lamdba的类型,要指定其Receiver,以限定它所在的布局。比如说slot是用在一个Column里面的,那么要给lamdba指定ColumnScope作为receiver,这样在实现lambada的时候就知道是作为Column的一部分,并且可以使用Column布局的特有相关参数,如左右居中和垂直排列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Composable
MyLayout(
     modifier: Modifier,
     content: @Composable ColumnScope.()->Unit
) {
     Column() {
          // 共用的一部分

          content()
     }
}

// 调用的地方
MyLayout { // this = ColumnScope
     // 隐式this指针指向一个ColumnScope对象,就像在一个Column中一样
     // 定制的部分
}

UI元素很多都会涉及到居中,对齐的调整,以及内部元素的排列,而对齐和排列又会明确的受到所在父布局的影响,比如说Box与Column的对齐和排列方式就是不同的。所以在使用slot时一定要明确 标注它所在的布局,以让调用者能够明确地知道lambda所在的布局作用域。

扩展阅读:

如何在ViewModel中使用平台相关的资源

我们在降Compose十八掌之『神龙摆尾』| Architecture中讨论过,ViewModel作为Domain层,目的是把逻辑尽可能的从UI层中抽出来,让UI尽可能的只做UI渲染。ViewModel也要做到平台独立,这样才方便移植和测试。ViewModel中吐出来的数据要是加工过的可以直接方便地在UI层展示的数据,如字符串或者图片。

但有一个问题,资源文件如何管理都是平台强相关的。对于要展示给用户的文案,也不可能直接把字符串传给UI,因为UI语言都要能够本地化以适应不同的国家和地区,当然了如果说不需要考虑多语言的问题,比如我的应用只给某一个语言使用,那当然也可以直接把处理好的字符串当作UiState传给UI层。

最为理想的解决方案就是ViewModel层定义一些状态码,对应着不同的提示语言,由UI负责一一对应的,把状态码再转成字符串。对于其他的资源也可以采用类似方式处理。这是从ViewModel输出到UI层的情况。

还会反过来,对于需要从UI层输入到ViewModel的资源,也是要去除平台的相关性,比如转成ViewModel中定义的状态码,或者转成原始数据类型String,或者转成平台无关的输入输出流等等。

字符串资源

对于Android平台来说,可以用一个简单的方式来解决字符串资源问题,因为资源的引用是一个整数,所以可以直接把资源的ID当作字段传给UI,Compose拿到后直接用函数stringResource取出来就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
data class UiState(
     val loading: Boolean = false,
     @StringRes val errorMsg: Int = 0
)

class HomeViewModel {
     val timeout = UiState(false, R.string.error_message_timeout)
     _state.update(timeout)
}

// 在Compose中
HomeScreen() {
    Text(stringResource(uiState.errorMsg))
}

虽然说这并不太通用,因为换成其他平台时可能不是用资源ID来获取资源,但转成状态码的方式也会很容易,所以问题不大。

如果是输入的话,在Compose中直接读取资源变成String传给ViewModel就好了。

扩展阅读:

图片资源

图片资源一般来说都是UI自己指定,但有些时候可能会有逻辑,比如一些需要经过运算才能得到的复杂的状态,其代表的Icon,由ViewModel来直接指定要好一些。图片资源也可以直接使用资源ID,然后在Compose中使用painterResource来获取:

1
2
3
4
5
6
7
8
9
data class UiState(
     @DrawableRes val icon: Int = 0
)

// in ViewModel
val state = UiState(R.drawable.ic_windy)

// in Compose
Icon(painterResource(uiState.icon))

如果是输入的话,可以在Compose中把图片资源转成输入流传给ViewModel去处理。

其他资源

其他资源如dimen或者color,也可以如法炮制。

输入的话,对于普通的资源像字符串资源,dimen或者color等读出来转成基础数据类型String,Int或者Array传给ViewModel就好。而像比较麻烦的资源,如Assets中的资源,就转成输入流传给ViewModel处理。

如何在常规函数中调用Composables

在Compose的开发过程中最为令人不爽的地方在于Compose 的API,只能在被注解@Composable标注的函数中调用,其他地方是无法调用的。一般来说,这个问题也不大,因为Compose的入口是肯定是一个composable啊,一坨坨的composables的调用最终会生成UI树

但有些地方却跑出了Composable之外,比如像很多UI元素的事件响应,比如Button,它的事件响应onClick接收的就是一个普通的lambda:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun MainContent(
    modifier: Modifier = Modifier,
    serviceOn: Boolean,
    context: Context
) {
    Column() {
        Button(
            onClick = { AccessibilityHelper.gotoChronosSettings(context) },
        ) {
            Text(stringResource(if (serviceOn) R.string.turn_off_service else R.string.enable_service))
        }
    }
}

在Button的onClick里面可以执行一些普通函数调用,比如调用ViewModel等,但是不可以调用Compose的API,因为它是非Composable的,已经跑到了Composable之外。有些场景,这会带来比较大的不方便。

响应点击按扭的方式可能有很多,有些是执行一些普通函数调用,但有些时候也会修改UI,大部分时候也会创建新UI,比如说会弹出对话框。对于修改UI,可以直接通过修改状态的值,状态的值发生改变会触发重组,进而UI状态就会改变(通过读取状态的值显示 不同的UI)。

对话框Dialog也是一个Composable,它只能被Composable调用,无法在Button的onClick中直接调用Dialog。解决的办法依旧是借助状态,用一个Boolean型值的状态,当其为true时显示Dialog,在Button的onClick中更改此状态为true,状态变了触发重组,在重组时值为true就会显示Dialog了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Composable
fun InputSettingsEntry(
    label: String,
    description: String,
    value: String,
    onChange: (String)->Unit
) {
    var showing by remember { mutableStateOf(false) }
     Button(onClick = { showing = true }) {
          Text(value)
     }

    if (showing) {
        InputDialog(
            title = label,
            message = description,
            value = value,
            onDismiss = { showing = false },
            onConfirm = {
                onChange(it)
                showing = false
            }
        )
    }
}

另外,其实Dialog本身的一些事件响应也都是非Composable的,都是通过状态来控制Dialog的显示与否。

扩展阅读:

总结

Jetpack Compose博大精深,看似简单就是一坨函数,但在实际项目使用中会遇到各种细节问题。遇到问题也不用慌,用我们的『降Compose十八掌』都能解决,没事就多读一读,理解了Compose的思想与原理,做到心中无剑,很多问题都能迎刃而解。

References

Comments