本章我们讨论如何基于上一章节介绍的工具,实现鸿蒙上的响应式架构。
我们基于基于MVVM 架构,主要讨论VM。
Flow + Transformer
ViewModel的核心思想是flow + transformer flow 是响应式数据的基础,transformer 负责数据转换。
举个栗子:
typescript
class BaseViewModel {
protected readonly viewModelScope = new Scope()
}
interface MyModel {
nameColor: string
name: string
}
class MyModelTransformer{
async transform(name: string, isLogin: boolean): MyModel {
return {
name: name,
nameColor: isLogin ? Color.Black : Color.Grey
}
}
}
class MyViewModel extends BaseViewModel {
private _nameFlow = mutableStateFlow('')
private _isLoginFlow = mutableStateFlow(false)
readonly modelFlow: Flow<MyModel>
private transfomer = new MyModelTransformer()
constructor() {
super()
this.modelFlow = combine2(this._nameFlow, this._isLoginFlow,
async (name: string, isLogin: string) => { return this.transfomer.transform(name, isLogin)}
)
.stateIn(this.viewModelScope, { name : '', nameColor: ''})
}
updateIsLogin(isLogin: boolean) {
this._isLoginFlow.value = isLogin
}
updateName(name: string) {
this._nameFlow.value = name
}
}
@ComponentV2
class MyComponent {
private scope = new Scope()
private viewModel = new MyViewModel()
@Local name: string = ''
@Local nameColor: string = ''
aboutToAppear(){
this.scope.launch(async (abortSignal) => {
await this.viewModel.modelFlow.collect(async(myModel) => {
this.name = myModel.name
this.nameColor = myModel.nameColor
})
})
}
build(){
Text(this.name).fontColor(this.nameColor)
.onClick(()=>{
this.viewModel.updateName(`name${util.generateRandomUUID()}`)
})
}
}
上面这个小demo比较清晰的展示了响应式架构的样子。 每次调用viewModel.updateName都会触发_nameFlow发射新值,进而触发transformer.transform,生成一个新的MyModel, UI 层监听modelFlow,去相应的更新数据。
用flow的优势是可以非常方便的流式处理数据,中间提供了多种操作符。 将数据转换抽象成transformer的优势是可以方便的做单元测试,因此我们需要保证transformer是无状态的,即确定的输入对应确定的输出。
UiModel Mapping
上述例子比较简单,如果我们是一个复杂的列表,列表的每一个Item 都有他自己的状态,那这些状态该怎样维护? 如果用Compose的话,remember 天然的可以做这个事情,但是ArkUI中没有类似的操作符,该如何做呢?
为了不丢状态,我们需要自己维护数据到UiModel的映射,"同一个数据",永远映射到同一个UiModel对象,这样我们在UI上就可以方便的使用对应的UiModel 里,而不怕Component 划出屏幕销毁后状态丢失。 注意,"同一个数据"我这里使用了引号,如何理解"同一个"呢?不同场景有不同的含义,比如一个消息列表,那可以用消息id 来判断是不是"同一个数据",同一个消息id,永远都使用同一个UiModel。
保证这样的映射关系还有一个很重要的好处:在List中,ListItem 绑定的是固定的UiModel,因此数据更新时无需每次都重建ListItem。
在List 内使用,即配合LazyForEach使用时,keyGenerator 的返回值应该是UiModel的内存地址,保证同一个UiModel对象,不会重新创建ListItem。 但是我们是无法直接访问到js 对象的内存地址的,js 中也没有提供对象hashCode的方法。为了解决这个问题,我们可以给每个对象加一个自增的Id,这里叫union_id,于是UiModel会变成这样:
typescript
class MyUiModel {
private static UNION_ID = 0
readonly unionId = `MyUiModel_${MyUiModel.UNION_ID++}`
}
这样,我们可以用unionId 来指代对象的唯一性,所以LazyForEach 可以这样写:
scss
List(){
LazyForEach(this.dataSource, (item: MyUiModel, index)=> {
ListItem(){
// UI 代码
}
}, (item: MyUiModel, index) => item.unionId)
}
为了维护UiModel 的稳定映射,现在提供一个基础工具类 ModelStore, 作为UiModel 的通用管理工具:
typescript
export class ModelStore<T> {
readonly map = new Map<string, T>()
getOrCreate(id: string, factory: (id: string) => T): T {
if (this.map.has(id)) {
return this.map.get(id)!
} else {
const model = factory(id)
this.map.set(id, model)
return model
}
}
tryGet(id: string): Nullable<T> {
return this.map.get(id)
}
clear() {
this.map.forEach(model => {
const clearFunc = (model as IClearable).clear
if (typeof clearFunc === 'function') {
(model as IClearable).clear()
}
})
this.map.clear()
}
}
使用的时候非常简单:
typescript
class MyUiModel {
private static UNION_ID = 0
readonly unionId = `MyUiModel_${MyUiModel.UNION_ID++}`
readonly id: string
name = new DistinctState('')
constructor(id: string) {
super()
this.id = id
}
static newInstance(id: string) : MyUiModel{
return new MyUiModel(id)
}
static makeId(data: Data): string {
return `MyUiModel_${data.id}`
}
update(data: Data) {
this.name.update(data.name)
}
}
// 使用
const modelStore = new ModelStore()
//假设这是数据
//interface Data {
// id: stirng
// name: string
//}
const data: Data = {id: '1', name : 'Sean'}
const myUiModel = this.modelStore.getOrCreate(MyUiModel.makeId(data.id), MyUiModel.newInstance)
myUiModel.update(data)
最终版本
将上述所有东西组合起来,就实现了我们的最终版本啦,来看一下整体的架构:
typescript
//假设这是数据
//interface Data {
// id: stirng
// name: string
//}
class BaseViewModel {
protected readonly viewModelScope = new Scope()
}
class MyUiModel {
private static UNION_ID = 0
readonly unionId = `MyUiModel_${MyUiModel.UNION_ID++}`
readonly id: string
readonly name = new DistinctState('')
readonly nameColor = new DistinctState('')
constructor(id: string) {
super()
this.id = id
}
static newInstance(id: string) : MyUiModel{
return new MyUiModel(id)
}
static makeId(data: Data): string {
return `MyUiModel_${data.id}`
}
update(name: string, nameColor: string) {
this.name.update(name)
this.nameColor.update(nameColor)
}
}
class MyModelTransformer{
async transform(dataList: Data[], isLogin: boolean, modelStore: ModelStore<MyUiModel>): MyModel {
const result: MyUiModel[] = []
for (const data of dataList) {
const uiModel = modelStore.getOrCreate(MyUiModel.makeId(data), MyUiModel.newInstance)
uiModel.update(data.name, isLogin ? Color.Black : Color.Grey)
result.push(uiModel)
}
return result
}
}
class MyViewModel extends BaseViewModel {
private _dataListFlow = mutableStateFlow<Data[]>([])
private _isLoginFlow = mutableStateFlow(false)
readonly modelFlow: Flow<MyUiModel[]>
private transfomer = new MyModelTransformer()
private modelStore = new ModelStore<MyUiModel>()
constructor() {
super()
this.modelFlow = combine2(this._dataListFlow, this._isLoginFlow,
async (dataList: Data[], isLogin: string) => { return this.transfomer.transform(
dataList, isLogin, this.modelStore)}
)
.stateIn(this.viewModelScope, [])
}
updateIsLogin(isLogin: boolean) {
this._isLoginFlow.value = isLogin
}
updateData(dataList: Data[]) {
this._dataListFlow.value = dataList
}
}
// UI
@ComponentV2
class MyComponent {
private scope = new Scope()
private viewModel = new MyViewModel()
private dataSource = new MyUiModelDataSource()
aboutToAppear(){
this.scope.launch(async (abortSignal) => {
await this.viewModel.modelFlow.collect(async(myModeList) => {
this.dataSource.submit(myModeList)
})
})
}
build(){
List(){
LazyForEach(this.dataSource, (item: MyUiModel, index)=> {
ListItem(){
Text(this.name.state).fontColor(this.nameColor.state)
}
}, (item: MyUiModel, index) => item.unionId)
}
}
}
这套架构还可以继续优化,比如,将MyUiModel 对应的Ui 样式也放到UiModel 内,这样数据描述与Ui描述就会内聚到一个类中, MyComponent 相当于一个壳,没有任何Ui细节,优化后的MyUiModel如下:
typescript
export interface CellBuildContext<T> {
data: T
context: CellBuildContext // 这里放给UI用的context信息
}
class MyUiModel {
private static UNION_ID = 0
readonly unionId = `MyUiModel_${MyUiModel.UNION_ID++}`
readonly id: string
readonly name = new DistinctState('')
readonly nameColor = new DistinctState('')
readonly cellBuilder = wrapBuilder(Builder)
constructor(id: string) {
super()
this.id = id
}
static newInstance(id: string) : MyUiModel{
return new MyUiModel(id)
}
static makeId(data: Data): string {
return `MyUiModel_${data.id}`
}
update(name: string, nameColor: string) {
this.name.update(name)
this.nameColor.update(nameColor)
}
}
@Builder
function Builder(ctx: CellBuildContext<MyUiModel>) {
Text(ctx.data.name.state).fontColor(ctx.data.nameColor.state)
}
UI 会变成这样:
typescript
@ComponentV2
class MyComponent {
private scope = new Scope()
private viewModel = new MyViewModel()
private dataSource = new MyUiModelDataSource()
aboutToAppear(){
this.scope.launch(async (abortSignal) => {
await this.viewModel.modelFlow.collect(async(myModeList) => {
this.dataSource.submit(myModeList)
})
})
}
build(){
List(){
LazyForEach(this.dataSource, (item: MyUiModel, index)=> {
ListItem(){
item.cellBuilder.builder({
data: item
context: {} as CellBuildContext // 这里放给UI用的context信息
})
}
}, (item: MyUiModel, index) => item.unionId)
}
}
}
关于「ArkTs 开发架构」的介绍就告一段落了,如果大家在使用过程中有任何问题,欢迎留言讨论。 Android工程师的kmp(kotlin/js) for harmony开发指南 这一系列文章旨在系统性的提供一套完整的Kotlin/Js For Harmony的解决方案。后续系列文章会介绍如何复用ViewModel,序列化卡顿优化,鸿蒙开发套件,架构设计思路等等,欢迎关注!