使用 Compose 时长两年半的 Android 开发者,又有什么新总结?

9,107 阅读3分钟

大家好啊,我是使用 Compose 时长两年半的 Android 开发者,今天来点大家想看的东西啊,距离上次文章也已经过去一段时间了,是时候再次总结一下了。
期间一直在实践着之前文章说的使用 Compose 编写业务逻辑,但随着业务逻辑和页面越来越复杂,在使用的过程中也遇到了一些问题。

Compose Presenter

上一篇文章中有提到的用 Compose 写业务逻辑是这样写的:

@Composable
fun Presenter(
    action: Flow<Action>,
): State {
    var count by remember { mutableStateOf(0) }

    action.collectAction {
        when (this) {
            Action.Increment -> count++
            Action.Decrement -> count--
        }
    }

    return State("Clicked $count times")
}

优点在之前的文章中也提到过了,这里就不再赘述,说一下这段时间实践下来发现的缺点:

  • 业务复杂后会拆分出非常多的 Presenter,导致在最后组合 Presenter 的时候会非常复杂,特别是对于子 Presenter 的 Action 处理
  • 如果 Presenter 有 Action,这样的写法并不能很好的处理 early return。

一个一个说

组合 Action 处理

每调用一个带 Action 的子 Presenter,就至少需要新建一个 Channel 以及对应的 Flow,并且需要增加一个对应的 Action 处理,举个例子

@Composable
fun FooPresenter(
    action: Flow<FooAction>
): FooState {
    // ...
    // 创建子 Presenter 需要的 Channel 和 Flow
    val channel = remember { Channel<Action>(Channel.UNLIMITED) }
    val flow = remember { channel.consumeAsFlow() }
    val state = Presenter(flow)
    LaunchedEffect(Unit) {
        action.collect {
            when (it){
                // 处理并传递 Action 到子 Presenter中
                is FooAction.Bar -> channel.trySend(it.action)
            }
        }
    }

    // ...

    return FooState(
        state = state,
        // ...
    )
}

如果页面和业务逻辑复杂之后,组合 Presenter 会带来非常多的冗余代码,这些代码只是做桥接,没有任何的业务逻辑。并且在 Compose UI 中发起子 Presenter 的 Action 时也需要桥接调用,最后很容易导致冗余代码过多。

Early return

如果一个 Presenter 中有 Action 处理,那么需要非常小心的处理 early return,例如:

@Composable
fun Presenter(
    action: Flow<Action>,
): State {
    var count by remember { mutableStateOf(0) }
    
    if (count == 10) {
        return State("Woohoo")
    }

    action.collectAction {
        when (this) {
            Action.Increment -> count++
            Action.Decrement -> count--
        }
    }

    return State("Clicked $count times")
}

count == 10 时会直接 return,跳过后面的 Action 事件订阅,造成后续的事件永远无法触发。所以所有的 return 必须在 Action 事件订阅之后。

当业务复杂之后,上面两个缺点就成为了最大的痛点。

解决方案

有一天半夜我看到了 Slack 的 Circuit 是这样写的:

object CounterScreen : Screen {
  data class CounterState(
    val count: Int,
    val eventSink: (CounterEvent) -> Unit,
  ) : CircuitUiState
  sealed interface CounterEvent : CircuitUiEvent {
    object Increment : CounterEvent
    object Decrement : CounterEvent
  }
}

@Composable
fun CounterPresenter(): CounterState {
  var count by rememberSaveable { mutableStateOf(0) }

  return CounterState(count) { event ->
    when (event) {
      is CounterEvent.Increment -> count++
      is CounterEvent.Decrement -> count--
    }
  }
}

这 Action 原来还可以在 State 里面以 Callback 的形式处理,瞬间两眼放光,一次性解决了两个痛点:

  • 子 Presenter 不再需要 Action Flow 作为参数,事件处理直接在 State Callback 里面完成,减少了大量的冗余代码
  • 在 return 的时候就附带 Action 处理,early return 不再是问题。

好了,之后的 Presenter 就这么写了。期待再过半年的我能再总结出来一些坑吧。

为什么 Early return 会导致事件订阅失效

可能有人会好奇这一点,Presenter 内不是已经订阅过了吗,怎么还会失效。
我们还是从 Compose 的原理开始说起吧。
先免责声明一下:以下是我对 Compose 实现原理的理解,难免会有错误的地方。
网上讲述 Compose 原理的文章都非常多了,这里就不再赘述,核心思想是:Compose 的状态由一个 SlotTable 维护。
还是结合 Early return 的例子来说,我稍微画了一下 SlotTable 在不同时候的状态:

@Composable                                          
fun Presenter(                                       
    action: Flow<Action>,                           count != 10 | count == 10                            
): State {                                           
    var count by remember { mutableStateOf(0) }     |   State   |   State   |                                                    
    if (count == 10) {                              |   State   |   State   |                           
        return State("Woohoo")                      |   Empty   |   State   |                                   
    }                                               |           |           |          
    action.collectAction {                          |   State   |   Empty   |                               
        when (this) {                               |   State   |   Empty   |                          
            Action.Increment -> count++             |   State   |   Empty   |                                            
            Action.Decrement -> count--             |   State   |   Empty   |                                            
        }                                           |           |           |              
    }                                               |           |           |          
    return State("Clicked $count times")            |   State   |   Empty   |                                             
}                                                      

count != 10 的时候,SlotTable 内部保存的状态是包含 Action 事件订阅的,但是当 count == 10 之后,SlotTable 就会清空所有之后语句对应的状态,而之后正好包含了 Action 事件订阅,所以订阅就失效了。
我觉得这是 Compose 和 React Hooks 又一个非常相似的地方,React Hooks 的状态也是由一个列表维护的
再举一个例子:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Column {
        var boolean by remember {
            mutableStateOf(true)
        }
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
        Button(onClick = {
            boolean = !boolean
        }) {
            Text(text = "Hide counter")
        }

        if (boolean) {
            var a by remember {
                mutableStateOf(0)
            }
            Button(onClick = {
                a++
            }) {
                Text(text = "Add")
            }
            Text(text = "a = $a")
        }
    }
}

这段代码大家也可以试试。当我做如下操作时:

  • 点击 Add 按钮,此时显示 a = 1
  • 点击 Hide counter 按钮,此时 counter 被隐藏
  • 再次点击 Hide counter 按钮,此时 counter 显示,其中 a = 0

因为当 counter 被隐藏时,包括变量 a 在内所有的状态都从 SlotTable 里面清除了,那么新出现的变量 a 其实是完全一个新初始化的一个变量,和之前的变量没有任何关系。

总结

过了大半年,也算是对 Compose 内部实现原理又有了一个非常深刻的认识,特别是当我用 C# 自己实现一遍声明式 UI 之后,然后再次感叹:SlotTable 真是天才般的解决思路,本质上并不复杂,但大大简化了声明式 UI 的状态管理。