从零开始的鸿蒙开发之旅(三)——状态管理

前言

本文基于鸿蒙Next beta1,beta2的新特性暂未深入研究和使用,且目前beta2已经可以试用。

现在客户端所流行的结构无非就是MVVM或者MVI。MVI的特点是不仅把数据变化做了响应,连交互都封装了,鸿蒙目前最适合的是MVVM。 鸿蒙使用声明式UI开发框架,那么和所有的流行的大前端框架一样,我们需要处理前端组件间的数据通信和变化响应,也就是所谓的状态管理。而MVVM中的viewmodel可以狭义的理解为状态管理。

MVVM

View 是用户界面的呈现,负责展示数据和接收用户输入。它确实可以触发事件,但这些事件不会直接修改Model,而是通过调用ViewModel中的方法来间接实现。ViewModel封装了对Model数据的操作和业务逻辑,实现了View和Model之间的解耦。

ViewModel 是这一架构的核心,它监视Model的变化并自动更新View(通常借助于数据绑定机制)。同时,ViewModel也为View提供可观察的数据和命令(即处理View事件的方法),使得当View发生变化时(例如用户点击),ViewModel能够捕捉这些事件并相应地更新Model,或者执行其他操作,如发起网络请求。因此,ViewModel不仅需要在Model更新时通知View响应,还要处理View事件以间接影响Model,确保了数据流动的单向性或通过ViewModel的双向绑定机制来维护数据同步和状态一致性。

案例

讲完了最基本的MVVM的概念。下面我们研究一个案例。

我有一个组件Parent,这个Parent是页面的基本布局,有一个数据源提供了一组List items,Parent要用类似于RecyclerView这样的布局把items展示出来,这个items有一个基本的功能就是select选中,我还有一个bar组件,这个组件在Parent里,他的作用是对items执行全选,以及打印选中的组件们。

js 复制代码
export class Item {
  public isSelect: boolean = false
  public name: string = ''
}

@Component
export struct Parent {
  @State childs: Item[] = []

  build() {
    List({ space: 20, initialIndex: 0 }) {
      ForEach(this.childs, (item: Item, _index) => {
        ListItem() {
          Child({data: item})
        }
      }, (item: string) => item)
    }
  }
}

@Component
export struct Child {
  @State data: Item = new Item()

  build() {
    Column() {
      Text(`${this.data.name}`)
    }.onClick(() => {
      this.data.isSelect = !this.data.isSelect
    })
  }
}

@Component
export struct Bar {
  build() {
    Row() {
      Text('全选').onClick(()=> {
        console.log('child全部变true')
      })
      Text('操作').onClick(()=> {
        console.log('打印true的child')
      })
    }
  }
}

上面是一个最基本的鸿蒙实现,这里面有非常多的问题,大家可以先自己想一想要怎么改。然后我再写一个jetpack compose的实现。

kotlin 复制代码
data class Item(val id: Int, var isSelected: Boolean = false, val name: String)
class MyViewModel : ViewModel() {
    private val _items = MutableLiveData<List<Item>>(emptyList())
    val items: LiveData<List<Item>> get() = _items

    fun setItems(items: List<Item>) {
        _items.value = items
    }

    fun toggleAllSelection(isSelected: Boolean) {
        val updatedItems = _items.value?.map { it.copy(isSelected = isSelected) } ?: emptyList()
        _items.value = updatedItems
    }

    fun printSelectedItems() {
        val selectedItems = _items.value?.filter { it.isSelected } ?: emptyList()
        selectedItems.forEach { println(it) }
    }
}
@Composable
fun Child(item: Item, onItemSelected: (Item) -> Unit) {
    Row(
        Modifier.clickable { onItemSelected(item.copy(isSelected = !item.isSelected)) }
            .padding(8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Checkbox(item.isSelected, onCheckedChange = { onItemSelected(item.copy(isSelected = it)) })
        Text(item.name)
    }
}

@Composable
fun Parent(viewModel: MyViewModel) {
    val items by viewModel.items.observeAsState(emptyList())

    Column(modifier = Modifier.padding(16.dp)) {
        // 使用 LazyColumn 来显示列表项
        LazyColumn(
            contentPadding = PaddingValues(vertical = 8.dp)
        ) {
            items(items) { item ->
                item?.let {
                    item { Child(item, viewModel::toggleItemSelection) }
                }
            }
        }

        // 底部操作栏
        Bar(viewModel)
    }
}

@Composable
fun Bar(viewModel: MyViewModel) {
    Row(
        Modifier.fillMaxWidth().padding(top = 16.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Button(onClick = { viewModel.toggleAllSelection(true) }) {
            Text("全选")
        }
        Button(onClick = viewModel::printSelectedItems) {
            Text("操作")
        }
    }
}

可以看到compose和鸿蒙最大的区别就是,compose具备单独的viewmodel实现,在viewmodel里放了model和对操作的响应,然后只需要在View中状态上提,那么自然而然可以在页面的组件间进行数据的互相操作和响应。

其实MVVM要解决的难题就一句话,数据的更改组件要怎么收到通知

这个数据的更改有可能是组件Parent,也有可能是组件Child,也有可能是某个Bar,而数据被改了,大家都要知道,在Android中google贴心的为大家设计了viewmodel,甚至还有kotlin的flow这么好用的东西,尽管状态的上提和副作用也不是那么简单,但是写起来非常的结构化。

好了,吹完了Android,再回到鸿蒙,看看我一开始的代码有哪些问题,又要怎么改。

组件状态

组件状态其实很好理解,就是我组件里有一个变量,这个变量的变更会影响到组件的渲染,那么这个组件就是有状态的,一个永远不会变的组件是没有状态的,写过flutter的同学应该非常清楚。

在鸿蒙里对应组件状态的是一个注解(对不起,鸿蒙应该叫装饰器)@State,用@State装饰的变量的变化会导致组件响应变化,它私有,它必须显式赋值,它可以响应对象自身的赋值变化。这里必须强调一下。

js 复制代码
// class属性的赋值可以观察到
this.title.value = 'Hi';
// 嵌套的属性赋值观察不到
this.title.name.value = 'ArkUI';

样例代码清晰地表示了可以观察到的变化的范围(这里其实有点比kotlin好用了)。

当然除了@State还有一些其他的装饰器。

  • @Prop 父子单向
  • @Link 父子双向
  • @Provide @Consume 后代双向
  • @Observed @ObjectLink 观察嵌套类对象属性变化

有一定开发经验的一下就能知道这些装饰器分别处理什么场景了,我并不打算一个个详解这些装饰器,有兴趣的可以直接去开发文档看。

我们现在回到我们的Demo分析一下

js 复制代码
@Component
export struct Child {
  @State data: Item = new Item()

  build() {
    Column() {
      Text(`${this.data.name}`)
    }.onClick(() => {
      this.data.isSelect = !this.data.isSelect
    })
  }
}

Child组件里的data的点击事件是需要告诉Parent的,而且还有一个Bar可以做全选,那么Parent外也要把变化传递给Child,而我们又知道@State是一个组件内状态变化的装饰器,明显@State是不够的。

在修改Child之前,我们先要修改一下Bar的代码,Bar目前只有log的功能,我们现在回到需求,全选需要把child是所有select改成true,而操作需要打印所有true的child。有些同学也许会想把childs传递到bar里然后修改,但我的想法是响应就做响应即可,没必要多传递一层数据,下面是我修改的代码。

js 复制代码
@Component
export struct Bar {
  performAction: (() => void) = () => {
  }
  selectAll: (() => void) = () => {
  }
  build() {
    Row() {
      Text('全选').onClick(()=> {
        this.selectAll()
      })
      Text('操作').onClick(()=> {
        this.performAction()
      })
    }.width('100%').justifyContent(FlexAlign.SpaceBetween)
  }
}
Bar({performAction: () => {
  this.childs.forEach((item) => {
    if (item.isSelect) {
      console.log(`${item.name}`)
    }
  })
}, selectAll: () => {
  this.childs.forEach((item) => {
    item.isSelect = true
  })
}})

这里大家会发现一个问题,为什么我点了全选没反应呢,原因很简单,@State没传递啊,那么我们就得换装饰器。

@Link data: Item这是不是就双向绑定了哈哈。但其实不对,代码会报错,因为Item是Object,所以要改成@ObjectLink data: Item,同时给Item加上注解@Observed,这样我们的代码就完成了我们想要的效果。

但是现实往往是残酷的,在正式开发过程中,我们肯定会遇到额外的需求,比如:

  • 我在点操作的时候弹窗做提醒并显示名称或数量,例如你确定要删除<>和<>等n个元素吗
  • Edit模式,在顶部增加bar控制能不能进入edit模式,没进入的时候不给显示点击按钮和点击操作
  • 其他页面或者小的浮窗也想要获取到这些数据,也想要改这些数据怎么办
  • 现在只是child响应UI变化,但如果我变化的时候需要做数据处理要怎么办,build代码块里不能写逻辑呀

在上面复杂的问题里,其实归根到底可以理解为习惯了MVC结构的开发者在MVVM结构中没有VM层该如何在复杂的页面交互中进行开发。

其实大家可以想一下,我们不过就是希望把状态进一步地上提,上提到某个足够在所有有需要用到这个状态的所有组件都能拿到组件状态的地方,然后还要观察和响应控制。在鸿蒙里因为没有严格意义上的ViewModel,所以这里需要换一种方式,那么我们先讲存储再讲观察控制。

状态存储

有经验的同学肯定知道状态上提,要根据最小作用域原则状态上提,什么模块化减少耦合提高性能就不赘述了。那么这个作用于在鸿蒙有下面这几个。

  • LocalStorage: 页面级
  • AppStorage: 应用级
  • PersistentStorage: 文件级

大家可以根据自己的业务需求来选择合适的存储方案。

注意复杂的Object的数组对象的成员属性状态变化不一定能监听到,根据业务状态抽离需要的字段

状态监听

这个其实在鸿蒙里非常简单,使用Watch装饰器即可

js 复制代码
1.  @Component
1.  struct TotalView {
1.  @Prop @Watch('onCountUpdated') count: number = 0;
1.  @State total: number = 0;
1.  // @Watch 回调
1.  onCountUpdated(propName: string): void {
1.  this.total += this.count;
1.  }
1.
1.  build() {
1.  Text(`Total: ${this.total}`)
1.  }
1.  }

Watch装饰器后带方法,然后写一个方法作为监听回调。我必须要吐槽一下,在大型项目里不觉得这样写让页面结构变得很奇怪吗,把view和viewmodel写在一个文件里。我尝试过这个方法还不能是外部export方法。

最后

鸿蒙的优点也有不少,比如编译快上手简单等,但是作为一个Android开发,写过flutter写过compose,鸿蒙是写的最别扭的,beta2的文档里还有仓颉,给我整的有点害怕了。

相关推荐
SameX2 小时前
HarmonyOS Next 安全生态构建与展望
前端·harmonyos
SameX2 小时前
HarmonyOS Next 打造智能家居安全系统实战
harmonyos
Dnelic-3 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen5 小时前
MTK Android12 user版本MtkLogger
android·framework
Random_index9 小时前
#Uniapp篇:支持纯血鸿蒙&发布&适配&UIUI
uni-app·harmonyos
长亭外的少年12 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
鸿蒙自习室13 小时前
鸿蒙多线程开发——线程间数据通信对象02
ui·harmonyos·鸿蒙
建群新人小猿15 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
SuperHeroWu715 小时前
【HarmonyOS】鸿蒙应用接入微博分享
华为·harmonyos·鸿蒙·微博·微博分享·微博sdk集成·sdk集成
1024小神16 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri