阅读难度:简单(时间:20240306)
前言:
有幸参与到一个鸿蒙项目的开发,一边开发一边学习总结。
本文主要介绍状态绑定的使用与项目开发过程中遇到的问题及思考这类通用性的内容,不对具体的技术细节进行探讨,精力有限,官方文档更新也很频繁,具体细节可参考官方文档
【ArkTS】:HarmonyOS 优选的主力应用开发语言。ArkTS围绕应用开发在TypeScript生态基础上做了进一步扩展
【ArkUI】:HarmonyOS 下的一套声明式UI
UI 相关
如果有声明式UI开发的经验(如Flutter),那么鸿蒙开发就能事半功倍,如果精通 SwiftUI 及其主要机制,粗略的看下文档就能进入开发。
声明式UI的优点在于 易理解,组合方便,UI维护方便,上手简单。缺点在复杂页面上可能出现性能问题,不易排查优化,且学习曲线较为陡峭。
这次整个团队基本都是从零开始学习鸿蒙开发,印象最深的应该是:部分同学对页面渲染流程认知较少
前端初学者及部分多年经验工作者都存在该问题,在声明式UI开发中更加明显的暴露出来,初学者直接上手 声明式UI 会有一个渲染理解的过程。一个页面,对于前端开发,就是一个视图树,而渲染引擎会根据视图树来进行优化、绘制:
实际开发中的视图树规模会大得多,屏幕上看到的视图大部分都靠近视图树的末端。对视图的更改(例如:增删改查)本质上是对视图树、或通过视图树进行操作。
在将视图特性都 样板化、内敛 的声明式UI框架中,初学者能快速上手、并且做出性能不差的页面。但这不能成为降低在开发中对性能关注度的理由。
相反,在视图的层级结构上一定要有个清晰的认知,这样才能在出现性能、渲染、逻辑类问题时快速定位问题,并找出较优的解决方案,能够独自解决下面类似的问题:
- 为什么这个XX没达到预期效果?
- 在XX场景下的性能问题如何优化?
前端开发正式入门的里程碑之一应当包含:
大脑就是一台【渲染引擎】
双向绑定
在MVVM中,双向绑定很常见,不同的UI框架中实现方式不同,原理类似。iOS 可参考旧文(初见Combine 、 SwiftUI 中的 @State、@StateObject、@ObservedObject)
属性包装器也可唤做“装饰器”、“注解”,下文与官方文档中保持统一,使用“属性装饰器”代指)
【ArkUI中常见的属性装饰器简介】:
@Entry : 自定义页面入口 (Page,可实现onPageShow等页面生命周期方法)
@Component : 自定义组件 (View,需实现build方法)
@State : 状态变量(持有、双向)
@Prop: 状态变量(深拷贝,注意性能!!!、单向) 必须初始化
@Link: 状态变量(绑定、双向)
@Provide : 跨组件双向同步 (需配合 @Consume 使用,类似SwiftUI中的环境值)
@Observed : 装饰Class, 需配合 @ObjectLink、@Prop使用 (注意, 并非对标 SwiftUI中 iOS 17新增的Observable, 无多层级观察! 是iOS 17之前那种样板代码, 多层级嵌套的class每个都要用@Observed标识)
@ObjectLink : 配合@Observed双向绑定
@Reusable : 标识自定义组件可复用
@Builder : 标识方法为视图构造器 (默认值传递、$$强制引用传递可响应@State, 可参考Swift中Combine的$)
@BuilderParam : UI 参数 (配合@Require 可强制父组件传参)
@Styles : 用于样式复用、不支持参数 (组件内声明可访问this)
@Extend : 扩展组件样式、支持参数(仅可全局)
@AnimatableExtend: 可动画属性(仅可全局、参数必须为number类型或实现了AnimtableArithmetic<T>的自定义类型、只可调用括号内组件的方法)
@Require : 检验 @Prop 和 @BuilderParam是否需要构造传参 (不可单独使用)
@Watch : 状态变量更改通知, 属性包装起顺序放最内侧 (调试?)
@Track : 装饰class对象的单个属性以可观测各装饰器的更详细介绍和使用方式可参考官方文档
以状态绑定最常用到的 @State、@Prop、@Link、@Observed、@ObjectLink以及@Provide、@Consume来举个例子:
/// 数据
@Observed
export class MainViewModel {
sectionModel?: SectionModel = new SectionModel()
someNum: number = 0
runEvent(eventType:EventType) {
......
}
}
@Observed
export class SectionModel {
title?: string
someText?: string
}
/// 页面
@Entry
@Component
export struct MainPage {
@Provide viewModel: MainViewModel = new MainViewModel()
build() {
Column() {
SectionCardView({cardModel: this.viewModel.cardModel})
}
}
}
/// 自定义组件
@Component
export struct SectionCardView {
@ObjectLink cardModel: SectionModel
@State backOpacity: number
@State positionY: number | string = '100%'
build() {
Row() {
SectionCardView({backOpacity: this.backOpacity})
}
.opacity(this.backOpacity)
.position({y: this.positionY})
.onAppear(() => {
this.showAnim()
})
}
showAnim() {
animateTo({
duration: 300,
curve: Curve.EaseInOut,
playMode: PlayMode.Alternate
},() => {
this.backOpacity = 1
this.positionY = ''
})
}
}
/// 自定义组件
@Component
export struct EventCardView {
@Link backOpacity: number
@Consume viewModel: MainViewModel
build() {
Button({type: ButtonType.Normal}) {
Text('触发事件')
}
.width(20)
.backgroundColor(Color.White)
.opacity(this.backOpacity)
.onClick((event: ClickEvent) => {
this.viewModel.runEvent(EventType.TestEvent)
})
}
}
这是一个有着三层视图的示例,仅保留了属性装饰器部分以供理解参考,从代码可知:
- MainViewModel 与 SectionModel 都被
@Observed
装饰器修饰,它们的属性变化可被观察。 - MainPage 与子视图都被
@Component
修饰为自定义组件,意味着都必须实现视图构建方法:build()
。不同的是 MainPage 还被@Entry
修饰,标识其同时作为一个页面的入口,可供路由使用。 - MainPage 下 的viewModel并未被
@State
标识, 而是使用了有着跨层级访问特点的@Provide
。
【Tips】:
这是因为 ViewModel 贯穿着整个页面的逻辑处理。采用@State + @ObjectLink的方式在实际开发中存在着很多问题:层层传递极大的增加了样板代码,同时使得部分业务逻辑排查难度指数增加,在多人开发项目中VM层过于开放。使用 @Provide 可省去这些麻烦。 - SectionCardView 中使用了
@ObjectLink
来对外部传入的数据进行双向绑定。 - SectionCardView 中的动画属性 positionY、backOpacity 使用了
@State
标识,因为外部对该属性不敏感,也表明 SectionCardView 持有这两个属性,当进行动画操作时,自动更新与其绑定的视图。 - EventCardView 中,使用
@Link
来双向绑定父视图 SectionCardView 的 backOpacity 属性。 - EventCardView 中 使用
@Consume
来跨层级获取viewModel, 并在点击时,调用触发 viewModel 的 runEvent 方法。
【Tips】:
通常情况下@Consume
需与@Provide
的属性名一致来进行匹配。也可使用别名来匹配,如:@Provide('vm') a
与@Consume('vm') b
【一个开发中的小插曲】:“怎么视图没有自动刷新?”
某个模块的视图结构类似下面这样:
ForEach() { ForEach() { ForEach() { // 此处的数据更新没有刷新页面???” } } }
数据更新没问题,三层相关的数据也都有用ArrayObserve修饰。视图层的数据结构类似三维数组..直接改动各层数组元素的属性却有着差别,最外层可响应,中层数据不会刷新,最内层直接无响应。
原因:官方文档介绍ForEach会响应绑定的List个数变更,也会响应具体元素下的属性变更(通过@objectLink绑定),笔者想当然
的认为在这种多层ForEach下能够层层传递。
结论:数据长度不变时,ForEach的刷新由键值生成函数keyGenerator控制,最外层ForEach具体元素的属性能变化是因为变化由@ObjectLink告知了视图,可当内层又是一个ForEach时,内层ForEach的刷新受键值变化控制,所以直接改内层的属性值就不会刷新。那么第三层的ForEach自然更不会有反应。
处理:给ForEach所持有的数组元素对象下添加唯一标识ID(本该如此),当数据更新,或需要强制更新对应视图时,更新其标识ID,而这在内层的ForEach上的开销微乎其微。
架构选择
大部分页面都是MVVM
架构(永远的神!),有的模块因为业务最为复杂,逻辑联动非常多,借鉴了前段时间开发 VisionPro 时的经验,继续使用 MVI
(MVVM变种) 架构:
架构没有最好、只有最适合:适合业务,适合技术栈,适合团队。
着重说下“适合团队”,这次鸿蒙工程的开发过程中,团队二十多人,iOS、Android、Flutter、H5、Rust 什么技术栈都有,若对声明式 UI 没有任何经验,使用MVVM 本身就存在学习成本
,对从未接触过声明式UI的同学这个成本并不低。
感兴趣的朋友可以看看曾经笔者的MVI偶遇
结语
接触到的声明式UI开发中,鸿蒙的开发体验比 Flutter 好很多,与 SwiftUI 的差距还比较明显,不过官方更新迭代非常快,并且 ArkUI 与 SwiftUI 一样,都覆盖是其旗下全平台产品,期待后期的发展。
等有空了再搞一篇完整的自定义组件开发吧 :)