本篇文章整理一下我认为的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
关键字可以将一个属性委托给另外一个类,将取值和赋值的功能委托到该类的getValue
和setValue
函数中。
举个例子,在Compose开发中我们经常写下如下代码:
var isExpended by remember { mutableStateOf(false) }
Button(
onClick = {
isExpended = !isExpended
}
) {
if (isExpended) {
}
}
而此处的取值和赋值并非直接获取这个State,而是调用了State中的setValue
和getValue
扩展函数去获取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
,而比较复杂的状态和逻辑我们一般会使用Flow
或StateFlow
,举个例子我需要监听数据库或远端的数据变化,底层暴露了Flow
,我们此时可以使用map
或combine
转换成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委托是一个减少样板代码的高级特性,而在实际使用上是非常灵活的,除了官方的lazy
、ObservableProperty
和比较常见的用法,可以结合ViewBinding使用等等,本文还介绍了两种实际的开发场景供大家参考。相信大家在看完这篇文章之后对Kotlin委托使用有了更广的了解,不会停留于听过但不会用的阶段了。