上一篇文章在末尾处简略介绍了一个托付的场景,埋了一个坑。本篇文章整理一下我认为的Kotlin托付开发小技巧,并不一定是最佳实践,仅做学习参阅。

Kotlin的托付一般有两种运用办法:

  • 类托付,把类的功用托付到其他类中。
  • 特点托付,把某个域托付到一个类封装起来。

而本次就举几个小比如来助力更好地整理架构和开发。

类托付

Kotlin的类托付是指将类的功用托付给其他类。

class ApiDelegate(impl: ApiImpl) : Api by impl
interface Api {
    fun doSomething()
}
class ApiLocalImpl : Api {
    override fun doSomething() = TODO("Not yet implemented")
}

托付类能够彻底代替托付目标,托付目标的一切办法都会被托付类重载。托付类能够经过自己的结构函数来操控托付目标的初始化进程。这种机制能够经过运用关键字by来完成。在运用的时分能够以以下办法来运用:

val apiImpl = ApiImpl()
val apiDelegate = ApiDelegate(apiImpl)
apiDelegate.doSomething() // equals to apiImpl do something.

这种办法有什么用?

在看到这几行代码的时分,我脑海中冒出了这个问题,这种托付有什么用呢?带着这个问题,咱们往下看一下。

扩展类的功用

承继

在类似于API的场景中,咱们一般会规划出一个接口,并编写出多个完成类,这些类或是不同的完成逻辑亦或者是测验类。

当咱们运用的时分,一般只运用接口,不关怀也不应该关怀接口的完成细节。这会大大增强代码的解耦度可测验性可维护性

而在规划一个类时,假如不是为了被承继而规划,就将该类规划为制止承继。即Java中的final class ,而在Kotlin中的类默许是不可承继的。当将一个类开放承继会带来十分大的风险,举个比如:

open class Data {
    protected open val value: Int = 0
}
class OwData : Data() {
    public override val value: Int = 1
}
fun main() {
    val data = Data()
    data.value   // 报错
    val owData = OwData()
    owData.value // 不报错
}

在这段代码中,我将Data类中的value运用protected来声明,限制其的拜访权限。当我承继Data类并将value暴露出来,外部就能够获取到里面的数据了。这不利于维护类的数据安全,这种重写尽管不影响编译,可是可能会违背最小可见原则。因此在规划类的时分需求尽可能考虑final类。

组合代替承继

在需求扩展或修改一个final类的一起又需求保存其承继API时,咱们能够运用组合的办法来完成:

interface Api {
    fun doSomething()
    fun doSomething2()
}
interface Api2 {
    fun newApiLogic()
}
class NewApiImpl : Api, Api2 {
    private val apiImpl = ApiImpl()
    // 保存原有逻辑
    override fun doSomething() = apiImpl.doSomething()
    override fun doSomething2() {
        // 修改接口
    }
    // 扩展新功用
    override fun newApiLogic() {
        // new logic
    }
}

这个新的完成类保存了部分ApiImpl的逻辑,完成了新接口的逻辑。也便是说,有一部分逻辑托付给了apiImpl这个实例去完成,这有点像代理形式,实践上托付也能够单纯用作代理形式,并且是全自动的,也便是一开始的示例。而在Kotlin中能够运用by关键字去简化这个逻辑。

class NewApiImpl : Api by ApiImpl(), Api2 {
    // 省略重写doSomething逻辑
    override fun doSomething2() { /* change logic */ }
    override fun newApiLogic() { /* new logic */ }
}

而编译器的完成是会生成类似于前面的代码,将接口中的逻辑托付给完成类,若重写了接口则按新重写的逻辑来算。

这有点像承继,可是又是运用组合的办法来完成的,实践上并非承继ApiImpl类,它不能作为ApiImpl类来用,能够避免将ApiImpl类规划成可承继类,一起新类能够新增功用或承继其他类

依靠注入

上方代码是运用hardcode的办法来进行托付,在实践开发中如无必要主张将托付完成类给抽到结构函数中,如下所示:

class NewApiImpl(api: Api) : Api by api, APi2 {
    ....
}

这种办法创建出来的类在可维护性可测验性都较佳。在NewApiImpl的开发进程中就无需关怀也无法关怀Api的完成细节。

当我需求在单元测验中用测验Api来测验这个类时,就能够写出以下代码:

class TestApiImpl: Api { ... }
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
    @Test
    fun testFeature() {
        val testApiImpl = TestApiImpl()
        val newApi: Api = NewApiImpl(testApiImpl)
        // testLogic
    }
}

类托付小技巧

除了上方的根底用法削减样板代码,在实践开发中还能够辅助初始化函数用于削减大量逻辑代码。

举个比如,若我需求一个行列具有一定时间的防抖,在防抖前和防抖后做一定的逻辑。

val channel = Channel<Int>(0, BufferOverflow.DROP_OLDEST).apply {
    consumeAsFlow()
        .onEach {
            // pre logic
        }.debounce(500)
        .onEach {
            // after logic
        }.launchIn(coroutineScope)
}
channel.tryEmit(1)

当这个逻辑在需求复用在多个地方复用时写这么一大串代码是十分难受的,能够运用类托付将样板逻辑悉数抽离到一个类中。

@OptIn(FlowPreview::class)
class DebounceActionChannel<T>(
    coroutineScope: CoroutineScope,
    debounceTimeMillis: Long = 500L,
    preEach: (suspend (T) -> Unit)? = null,
    action: suspend (T) -> Unit,
) : Channel<T> by Channel(
    capacity = 0,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
) {
    init {
        consumeAsFlow().run {
            if (preEach != null) onEach(preEach) else this
        }.debounce(debounceTimeMillis)
            .onEach(action)
            .launchIn(coroutineScope)
    }
}

一起能够运用扩展函数来削减协程效果域参数传入:

@Suppress("FunctionName")
fun <T> ViewModel.DebounceActionChannel(
    debounceTimeMillis: Long = 500L,
    preEach: (suspend (T) -> Unit)? = null,
    action: suspend (T) -> Unit
): Channel<T> = DebounceActionChannel(viewModelScope, debounceTimeMillis, preEach, action)
@Suppress("FunctionName")
fun <T> LifecycleOwner.DebounceActionChannel(
    debounceTimeMillis: Long = 500L,
    preEach: (suspend (T) -> Unit)? = null,
    action: suspend (T) -> Unit
): Channel<T> = DebounceActionChannel(lifecycleScope, debounceTimeMillis, preEach, action)

在运用的时分直接声明即可:

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val homePageDataHolder: HomePageDataHolder
) : ViewModel() {
    val dataStoreHomePage get() = homePageDataHolder.homePage.value.takeIf { it != -1 } ?: 0
    private val updateHomePageChannel = DebounceActionChannel(
        debounceTimeMillis = 1000L,
        action = homePageDataHolder::setHomePage
    )
    fun updateHomePage(homePage: Int) {
        updateHomePageChannel.trySend(homePage)
    }
}

需求注意的是,这种运用办法尽管便利,可是它有一个缺点,即失去了逻辑的封装性。例如上方生成的是实打实的Channel实例,假如开发者运用不当,把它作为一般的Channel来运用的话,这就可能会造成一些奇怪的问题了。

特点托付

关于特点托付的运用和有比较多的材料能够查阅,主张查看官方文档,我这边就简略介绍一下,经过by关键字能够将一个特点托付给别的一个类,将取值和赋值的功用托付到该类的getValuesetValue函数中。

举个比如,在Compose开发中咱们常常写下如下代码:

var isExpended by remember { mutableStateOf(false) }
Button(
    onClick = {
        isExpended = !isExpended
    }
) { 
    if (isExpended) {
    }
}

而此处的取值和赋值并非直接获取这个State,而是调用了State中的setValuegetValue扩展函数去获取State中的value值和给State中的value仿制。

// SnapshotState.kt
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
    this.value = value
}
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

而官方供给了这两个函数的接口,ReadOnlyProperty仅可读,ReadWriteProperty可写可读

// Interfaces.kt
public fun interface ReadOnlyProperty<in T, out V> {
    public operator fun getValue(thisRef: T, property: KProperty<*>): V
}
public interface ReadWriteProperty<in T, V> : ReadOnlyProperty<T, V> {
    public override operator fun getValue(thisRef: T, property: KProperty<*>): V
    public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}

当完成了setValue函数和getValue函数后,就能够运用by关键字托付了。

特点托付小技巧

UI状况托付

在Compose开发中,咱们常常有意无意地会用到MVI架构。在ViewModel层会维护一些UI状况供View层监听,而UI状况中包含了大量的状况和逻辑。举个比如,这个比如在我的上一篇文章中有简略介绍:

data class NoteScaffoldState(
    val contentState: NoteContentState = NoteContentState(),
    val bottomBarState: NoteBottomBarState = NoteBottomBarState()
)

在ViewModel中维护状况时,若比较简略的状况咱们能够运用Compose中的State,而比较复杂的状况和逻辑咱们一般会运用FlowStateFlow,举个比如我需求监听数据库或远端的数据变化,底层暴露了Flow,咱们此刻能够运用mapcombine转换成UI状况。

总而言之,咱们只需求UI状况,而其他相关逻辑我不关怀,否则会造成ViewModel层十分臃肿。如下:

class NoteViewModel : ViewModel() {
    val uiState: StateFlow<NoteScreenState> // 只需求这个状况,不关怀逻辑
}

因此咱们能够将这部分逻辑给包装出去,完成托付接口:

class NoteRouteStateFlowDelegate(
    // 一堆参数
) : ReadOnlyProperty<ViewModel, StateFlow<NoteScreenState>> {
    // 一堆逻辑
    private val noteScreenState: StateFlow<NoteScreenState>
    override fun getValue(thisRef: ViewModel, property: KProperty<*>): StateFlow<NoteScreenState> {
        return noteScreenState
    }
}

在运用的时分就不关怀其他逻辑了:

val uiState: StateFlow<NoteScreenState> by NoteRouteStateFlowDelegate(...)

一般运用状况Flow免不了合流、转换等等逻辑,而运用托付的办法是极端便利的,还是上面那个比如,一个Scafold State需求Content State和Bottom Bar State两个UI状况。而这两个类的逻辑能够托付给到相应的类去完成。

class NoteRouteStateFlowDelegate(...) : ReadOnlyProperty<ViewModel, StateFlow<NoteScreenState>> {
    private val noteBottomBarState: StateFlow<NoteBottomBarState>
        by NoteBottomStateFlowDelegate(...)
    private val noteContentState: StateFlow<NoteContentState?>
            by NoteContentStateFlowDelegate(...)
    private val noteScreenState: StateFlow<NoteScreenState> = combine(
        noteContentState.filterNotNull(), noteBottomBarState
    ) { noteContentState, noteBottomBarState ->
        NoteScreenState.State(
            NoteScaffoldState(
                contentState = noteContentState,
                bottomBarState = noteBottomBarState
            )
        )
    }.stateIn(...)
    override fun getValue(thisRef: ViewModel, property: KProperty<*>): StateFlow<NoteScreenState> {
        return noteScreenState
    }
}
// 其他托付类
class NoteContentStateFlowDelegate(...)
    : ReadOnlyProperty<Any?, StateFlow<NoteContentState?>> {
    private val noteContentStateFlow: StateFlow<NoteContentState?>
    override fun getValue(
        thisRef: Any?,
        property: KProperty<*>
    ): StateFlow<NoteContentState?> {
        return noteContentStateFlow
    }
}

经过thisRef参数能够获取到ViewModel实例,此刻获取viewModelScope协程效果域十分便利,某些情况下也能够削减传参数量,增加代码简练度。

还有一点需求说明的是,经过托付生成的特点是无法获取到托付类的任何逻辑或细节的,就算你不小心把哪个特点或API暴露出去了(尽管不主张这么干)也没有联系。

class DataDelegate : ReadOnlyProperty<Any?, String> {
    val file: File = File("") // 不小心暴露了个特点
    fun api() {
        /* 不小心暴露了个API */
    }
    private val value: String = ""
    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value
    }
}
fun main() {
    val data: String by DataDelegate()
    // 此处无法获取到不小心暴露出来的 file 和 api
}

假如你乐意的话,能够合作Dagger Hilt运用,避免注入进去的依靠暴露出去了。

class DataDelegate @Inject constructor() : ReadOnlyProperty<Any?, String> {
    @Inject
    lateinit var someUseCase: UseCase
    private val value: String = ""
    override fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return value
    }
}

总结

Kotlin托付是一个削减样板代码的高档特性,而在实践运用上是十分灵敏的,除了官方的lazyObservableProperty和比较常见的用法,能够结合ViewBinding运用等等,本文还介绍了两种实践的开发场景供我们参阅。信任我们在看完这篇文章之后对Kotlin托付运用有了更广的了解,不会停留于听过但不会用的阶段了。

参阅

  • Effective Java

  • 类托付官方文档

  • 特点托付官方文档

  • Kotlin 根底 | 托付及其应用 —— 唐子玄