成为自己(三):鸿蒙 Harmony 编码篇

前言

在介绍完 HarmonyOS 系统架构之后,相信大家对 Harmony 的生态以及玩法有了一个大致的了解。有了些许理论概念之后,我们正式进入「激动人心」的动手阶段,用什么工具开发(《工具篇》),在什么平台上(《系统篇》),怎样去开发(《编码篇》),达成什么结果(《实战篇》),明确了具体学习路径,才能逐步提高自己的技术能力。

对于编码篇,笔者一直在思考要怎么去写,首先肯定不能像开发社区中的开发 API 那样进行枯燥的介绍(实测容易走神,且效果不佳,过目就忘),但又不能打开 ChatGPT,告诉它你要实现 xx 功能,请使用 Harmony ArkTS 实现(BTW GPT-4 还没加进 ArkTS 相关的知识进行训练学习),这样零散的碎片化学习肯定也是事倍功半。更不能直接从面向对象、方法、变量这么基础的知识讲起。思虑再三,笔者决定先通过基础语法范式,同时结合自己的理解,尽量写得生动一点。同时在整个篇章中穿插一些实践经验和代码,来尽量让读者有代入感。同时,因为 API 太多,不能面面俱到,所以本篇只介绍我们常用到的注入网络、UI、算法库等功能模块。

最后,还是建议,实践出真知,所以看的过程中,也可以打开 DevEco-Studio 敲敲代码,多做尝试,会更加有趣。

ArkTS

我们在《Harmony 调研-工具篇》中对 ArkTS 语言做了一个概括性的介绍,即下面这张图:

有过 JS/TS 编程语言基础的开发同学在接触 ArkTS 时,可以说是基本「零门槛」,同时 JS/TS 的基础语法也简单易懂,即使是 Java/C/C++ 选手也能很快地入手 ArkTS 的语法逻辑,而对于一些特殊的「Harmony 定制」的语法特色或 API,则还是需要动手练,熟能生巧。Harmony 的页面 UI 由一系列的组件组成(和 Flutter 的 Widget(控件)概念不同,ArkTS 使用了 Component(组件)的概念来实现页面的 UI 化),组件的更新由状态的改变来驱动。这么解释起来有点抽象,举个例子:

我们知道,手机的 UI 界面无非由文字和图像两种元素组成(动画、视频算图像帧,也是图像或动画图像的一种),通常我们通过代码去更新文字或图像时,必然需要先知道变化后的状态,在 ArkTS 里我们就需要用变量记录这个「状态」(@State),当组件中的属性绑定这个状态时,只要这个状态变化,组件对应的属性也即时发生变化,过渡到这个状态,在下图「天气」显示的例子中,我们只记录了 weatherNow 的状态,通过这个状态的变化"驱动" UI 上的文字控件和图片控件响应 weatherNow 的变化:

注:图中文字没有使用任何编程语法,只为举例释义

通过这个简单例子,我们应该就能理解 ArkTS 中所谓的通过对组件(TextComp & ImageComp)进行状态(weatherNow)管理,实现声明式 UI。

ArkUI 框架

根据上面的例子,我们可以将其代入全局地去看 ArkUI 的整个开发过程,会发现很多较难理解的地方,都会有「一点就通」的感觉。但是知道是怎么一回事,不代表自己就能动手把代码敲出来,最快学习一门新的技术的方式,需要到具体业务中落地,一个系统或者开发语言,非常庞大,大到方方面面,小到犄角旮耷,繁杂的 API,系统设计细节,不可能面面俱到,从实际出发,「按需开发,以点到面」,会越来越得心应手。

从上面的 ArkUI 框架图可以看出,HarmonyOS 支持两种范式的开发,包括类 Web 开发范式(HTML+CSS+JS)和声明式开发范式(ArkTS),无论从 TS 相较于 JS 语言的优势出发,还是 Harmony 平台本身的倾向,亦或是开发者对于接触新语言的兴趣,我们都推荐使用 ArkTS。

我们先初步直观感受下 ArkUI 的编码风格,我们知道 Android 传统用的是 XML 语言进行页面的开发,而 ArkTS 写页面则类似于下面这种结构:

WeatherPage.ets

less 复制代码
@Entry
@Component
struct WeatherPage {
  /**
   * 状态变量
   */
  @State weatherNow: number = 1

  build() {
    Column() {
      // 文字组件
      Text('天气:' + (this.weatherNow == 0 ? '多云' : '晴'))
        .fontColor(Color.Red)
        .fontSize($r('app.float.title_font_size'))
        .opacity(1.0)
      // 图标逻辑
      if (this.weatherNow == 0) {
        this.changeWeatherIcon($r("app.media.ic_weather_cloudy"))
      } else if (this.weatherNow == 1) {
        this.changeWeatherIcon($r("app.media.ic_weather_sunny"))
      }
      // 按钮组件
      Button() {
        Text('刷新')
          .margin(20)
      }.onClick(() => {
        this.weatherNow = this.weatherNow == 1 ? 0 : 1
      })

    }
  }

  @Builder changeWeatherIcon(icon: Resource) {
    // 图片组件
    Image(icon)
      .objectFit(ImageFit.Contain)
      .width($r('app.float.ic_weather_width'))
      .height($r('app.float.ic_weather_height'))
      .margin($r('app.float.ic_weather_margin'))
  }
}

代码很简单,实现了一个自定义的天气组件,这个组件的功能是,显示基本的天气信息(文字+图标),同时可以使用「刷新」按钮模拟网络拉取天气信息更新页面上的天气信息。我们逐段分析,首先代码中比较显眼的是 @Entry@Component@State三个"注解",官方将其称之为「装饰器」,这些装饰器定义了装饰对象的属性、作用,每个装饰器通过简洁的表达方式,定义了代码中重要且简单的行为逻辑,我们在下一节「装饰器」部分会具体来看 HarmonyOS 中的那些装饰器。就上述代码中的装饰器而言,@Component代表装饰的这个 struct 类型是一个自定义的组件,@Entry表示该组件是一个入口组件(注意:@Entry装饰的@Component为页面的总入口,一个页面有且仅有一个@Entry),@State即为状态变量。

有人可能会疑惑,自定义组件为什么要用 struct 而不是 class?
首先 TypeScript 本身是没有 struct 作为关键字的,所以 struct 只是 ArkTS 的扩展关键字,而且官方并没有对为什么这么设计做解释。从 struct 关键字本身的特性出发去考虑这个问题的话,笔者只能认为 struct 用来存储结构较为简单且固定的 UI 组件数据更加合理。

@Component修饰的自定义组件,必须要实现内建方法 build(),可以理解为组件内 UI 的编写都需要在 build()方法内实现,我们再看下被 @Component修饰的自定义组件源码:

CustomComponent

php 复制代码
declare class CustomComponent extends CommonAttribute {
  /**
   * 构造自定义 UI 组件
   * @form
   * @since 9
   */
  build(): void;
  /**
   * 组件生命周期-将要显示时的回调
   * @form
   * @since 9
   */
  aboutToAppear?(): void;
  /**
   * 组件生命周期-将要消失(不可见)时的回调
   * @form
   * @since 9
   */
  aboutToDisappear?(): void;

  /**
   * 定义组件内部子组件间的位置排布数据
   * @form
   * @since 9
   */
  onLayout?(children: Array<LayoutChild>, constraint: ConstraintSizeOptions): void;

  /**
   * 测量组件内部子组件大小排布数据
   * @form
   * @since 9
   */
  onMeasure?(children: Array<LayoutChild>, constraint: ConstraintSizeOptions): void;

  /**
   * onPageShow Method
   * @since 7
   */
  onPageShow?(): void;

  /**
   * onPageHide Method
   * @since 7
   */
  onPageHide?(): void;

  /**
   * onBackPress Method
   * @since 7
   */
  onBackPress?(): void;

  /**
   * PageTransition Method.
   * 页面间跳转动画
   * @since 9
   */
  pageTransition?(): void;
}

对于 ArkTS 中的 CustomComponent类继承了 CommonAttribute类,该类可以简单理解为定义了 UI 的各类像 长、宽、padding、margin、color、fontSize 等 UI 属性,可以不管,CustomComponent在这些属性的基础上,赋予了自定义组件构造(build())、测量排布(onLayout()/onMeasure())和基础组件生命周期管理(aboutToAppear()/aboutToDisappear()/onPageShow()/onPageHide()/onBackPress()/pageTransition())的能力。可以发现除了build()方法必须实现之外,添加了?的属性方法都可以选择性实现。

那么简单的,接下来我们要做的就是在build()方法里去「画 UI」了,有着丰富页面开发经验的同学,一定也发现了画页面的一些「套路」:

  1. 先确定页面布局,以 Android 为例,首先选择 Layout,是从上至下、从左到右、控件是线性关系、约束关系还是层叠关系等

  2. 在确定好的布局上,进行各种控件组合,基础的如文字、图片展示的样式,复杂的如嵌套、滑动列表、Tab 等控件

  3. 在基础控件没法满足业务需求的情况下,通过代码自己去实现更复杂的展示样式或者动画效果,如贝塞尔曲线、不规则形状等等

这套「方法论」放到 ArkTS 中也是一样,在build()方法中,我们首先需要定义 layout 布局,和 Android 对比下,可能更容易理解,HarmonyOS 的布局种类,分别是:

  • 线性布局(Row/Cloumn),即 Android 中的LinearLayout,区别在于LinearLayout通过orientation标签属性定义是水平还是垂直排布,而 HarmonyOS 则直接区分了两种布局,是行(horizontal)还是列(vertical)。

  • 层叠布局(Stack),对应于 Android 的FrameLayout,这个布局中的子组件可以以默认居中的方式在 z 轴(屏幕面向用户方向)上层叠排布。

  • 弹性布局(Flex),这个算是在 Android 中没有对应 Layout 的一种布局,但说是「弹性」,但笔者倾向于该布局更适用于规则、对称、规整类 UI 样式,原因在于该布局是约束于「主轴」和「交叉轴」的。交叉轴和主轴可以简单理解为屏幕的 x 和 y 轴的方向,同时会跟随屏幕旋转变化,即如果垂直方向做主轴,那么横屏后就会变成交叉轴。

  • 相对布局(RelativeContainer),很好理解,Android 中的 RelativeLayout,也是通过子组件id定义每个子组件间的排布关系。有依赖关系的子/父组件称为锚点。

  • 栅格布局(GridRow/GridCol),这个比较有意思,相当于把布局在行/列上切成了一个表格,在表格中放置子组件,而且表格中行/列的交叉点(Harmony 称之为「断点」),在不同尺寸屏幕上,断点间的取值不同,可以根据不同的屏幕尺寸定义不同的断点数量(最大支持 6 个断点)。在没有很好的实践之前,这个布局还没想到有什么落地场景。不过猜想,「折叠屏」系列手机用这种布局会有很好的体验,单屏展开后的大屏能容下更多的内容。

任何涉及到屏幕绘制的代码行为,都会涉及到性能问题,HarmonyOS 也一样,使用不同的布局,开发不同的效果,都会影响到绘制效率,而总结下来优化的方向主要有两点:

  1. 减少过度绘制的代码行为,如布局、UI 上下重叠,导致没必要的渲染

  2. 减少绘制次数,如由于代码不严谨,触发不同屏幕上因为要适配的关系绘制的次数不同

大致介绍了布局之后,我们再来看下系统提供的默认组件,对于一些有经验的开发者而言,一般组件像按钮(Button)、单选框(Radio)、文本显示(Text+Span)、文本输入(TextInput)、切换按钮(Toggle)、进度条(Progress)、提示(Popup)等都算是可以无门槛快速了解的,我们只要知道这些系统提供的控件提供了比较统一的基础 UI 样式,在该系统框架里,即使设置的一些组件参数再多样,在风格上也和 HarmonyOS 想开发者做的 UI 风格,具备设备一致性。

对于开发者而言,通过简单的 UI API 调用去实现业务和能力是不具备挑战性和可玩性的,更进一步,脱离于官方的 UI 风格,想实现各式各样赏心悦目的 UI 组件需要怎么去做。整体看下来,可能主要有两个方式:

  1. 通过基础组件和布局的排列组合,实现更加复杂的 UI 组件,这样做的优缺点有:

    1. 优点:实现简单;样式统一且稳定(官方组件)

    2. 缺点:无法实现更复杂的定制效果;组件堆叠性能不高;

  2. 通过 XComponent 绘制组件实现,该组件可以理解为 Android 中的 SurfaceView(亦或是TextureView),可以通过 Surface 承载 EGL 的渲染逻辑,我们可以在 Native (C/C++)代码实现 Shader 来实现更复杂生动同时高性能的视觉效果。(这里只做原理上的介绍,具体将在《实战篇》实践)

上面 .ets 的代码文件中还有一点没有讲的是 UI 和用户交互的部分,即事件(比如点击行为onClick()),除了简单的点击事件,还可以实现常规的拖拽(onDragXxx()`)、触摸(onTouch())。同时,前面的文章提到,HarmonyOS 能运行在各类 IoT 设备当中,因此自然支持如键鼠(折叠屏、华为 Pad 等)、手势事件(传感器算法)等。比较典型的手势事件可能就是在华为手机上,通过手势识别隔空滑动屏幕(刷小视频能力)。

装饰器

我们再来具体聊聊装饰器,对于简单的 UI 而言,@Component@Entry@State是比较常用的几个装饰器,但是更深入地去了解 Harmony 系统并且灵活运用到各类场景中自然是远远不够的。这一小节,将会以标题和示例的方式介绍,这样在用到的时候,通过标题在文章中更容易熟悉和查找。装饰器按功能分,同样可以分为几类:

  • UI 类(通过装饰器达到组件代码预览、复用、优化、自定义的效果):@Entry@Component@Preview@Builder@CustomDialog@Styles@Extend@BuilderParam

  • 数据状态类(通过装饰器达到状态数据监听、继承、消费、更新的效果):@State@Prop@StorageProp@StorageLink@LocalStorageProp@LocalStorageLink@Observed@ObjectLink@Link@Watch@Provide@Consume

我们接下来一个一个来看。

**@Preview**

意如其名,添加该装饰器后,可以通过 Dev-Eco Studio IDE Previewer 功能预览组件的样式。我们前面提到过通过 IDE 的 Previewer 功能可以实时查看组件的效果,但请注意,不是所有的组件都可以生成预览,极有可能在开发者使用了@Component定义好一个组件后,点击 Previewer 面板,看到的是如下的提示:

这个时候,上面的@Previewer装饰器就用到了。我们可以通过添加该装饰器达到组件在 Previewer 面板中的预览效果(但也需要注意,不是说所有的代码或者组件只要加上 **@Previewer**就可以得到预览的):

less 复制代码
@Preview({
  title: '天气组件',        //预览组件的名称
  deviceType: 'phone',    //指定当前组件预览渲染的设备类型,默认为 Phone
  width: 1080,            //预览设备的宽度,单位:px
  height: 2340,           //预览设备的长度,单位:px
  colorMode: 'light',     //显示的亮/暗模式,取值为 light 或 dark
  dpi: 480,               //预览设备的屏幕 DPI 值
  locale: 'zh_CN',        //预览设备的语言,如 zh_CN、en_US 等
  orientation: 'portrait', //预览设备的横竖屏状态,取值为 portrait 或 landscape
  roundScreen: false      //设备的屏幕形状是否为圆形
})
/**
 * 单定义该组件无法在 Previewer 中显示
 */
@Component
export struct WeatherComponent {
  build() {
    Text('天气组件')
  }
}

同时@Preview还支持设置一系列的属性,如代码注释中所述比较明确,不再赘述。

**@Builder**

@Builder的语法和作用和上面提到过的组件中的build()方法是一样的,定义了怎样去渲染一个自定义组件,通过该装饰器简化了重复代码,同时可以复用到其他布局当中,示例代码在上面的@Builder changeWeatherIcon(icon: Resource)中,不再赘述。Android 开发者可以将其理解为和 XML 布局中的<include />标签作用非常相似。

**@CustomDialog**

顾名思义,该装饰器是用来装饰自定义弹窗的,使用该装饰器,同时配合CustomDialogController类,我们就可以实现 HarmonyOS 上的弹窗效果:

scss 复制代码
@CustomDialog
struct CustomDialogDialog {
  controller: CustomDialogController
  cancel: () => void
  confirm: () => void

  build() {
    Column() {
      Text('切换城市')
        .fontSize(20)
        .margin({ top: 10, bottom: 10 })
      Image($r('app.media.icon'))
        .width(80)
        .height(80)
      Text('天气即将变化,确定要切换城市吗?')
        .fontSize(16)
        .margin({ top: 10, bottom: 10 })
      Flex({ justifyContent: FlexAlign.SpaceAround }) {
        Button('否')
          .onClick(() => {
            this.controller.close()
            this.cancel()
          }).backgroundColor(0xffffff).fontColor(Color.Black)
        Button('是')
          .onClick(() => {
            this.controller.close()
            this.confirm()
          }).backgroundColor(0xffffff).fontColor(Color.Red)
      }
      .margin({ bottom: 10 })
    }
  }
}

@Entry
@Component
struct CustomWeatherDemo {
  @State text: string = ""
  dialogController: CustomDialogController = new CustomDialogController({
    builder: CustomDialogDialog({ cancel: this.onCancel.bind(this), confirm: this.onAccept.bind(this) }),
    cancel: this.dismissDialog.bind(this),
    autoCancel: true
  })

  onCancel() {
    console.info('Callback when the first button is clicked')
    this.text = '停留在当前城市'
  }

  onAccept() {
    this.text = '切换到指定城市'
    console.info('Callback when the second button is clicked')
  }

  dismissDialog() {
    console.info('Click the callback in the blank area')
    this.text = '用户点击了空白处'
  }

  build() {
    Column() {

      Text(this.text)
        .fontSize(24)
        .margin({
          top: 10
        })

      Button('打开弹窗')
        .fontSize(24)
        .margin({
          top: 20
        })
        .onClick(() => {
          this.dialogController.open()
        })
    }
    .width('100%')
    .margin({ top: 10 })
  }
}

相较于其他装饰器而言,该装饰器在代码实现上还是比较复杂的,除了要定义整个弹窗的样式之外,还需要去定义控制器和回调函数去实现交互行为。除了简单的样式之外,在实现自定义弹窗的时候,需要特别注意controller的行为,除了实现弹窗里的 controller 之外,在使用时,也需要再定义一个dialogController并绑定弹窗内的 controller

**@Extend**

继承装饰器,区别于传统的类继承,该装饰器只能用于组件继承,面向对象的三大特性之一的继承有什么作用,相信不需要我阐述过多,对于组件而言,我们可以通过继承系统组件的方式,来扩展组件的能力,比如说对于一个文本组件,我们需要定义其点击的统一行为,我们来看代码:

less 复制代码
@Extend(Text) function checkCityWeather(fontSize: number, onClick: () => void) {
  .backgroundColor(Color.Red)
  .fontSize(fontSize)
  .onClick(onClick)
}


@Entry
@Component
struct CustomWeatherDemo {
  @State clickHint: string = ""
  @State currentWeather: string = "晴"

  onClickHandler() {
    this.currentWeather = "多云"
  }

  ...
  
  build() {
    Column() {

      Text(this.currentWeather)
        .fontSize(24)
        .margin({
          top: 10
        })
        .checkCityWeather(32, this.onClickHandler.bind(this))

        ...
    }
    .width('100%')
    .margin({ top: 10 })
  }
}

首先我们通过继承Text组件实现了该组件的一个点击能力,点击后的效果是将Text组件的背景变成红色。在build()构造函数中在Text组件中直接调用checkCityWeather()方法,并且绑定onClickHandler那么所有调用该方法的Text组件都会响应并实现checkCityWeather()方法中的效果。是不是很方便?

**@Styles**

@Style装饰器和@Extend非常相似,都能达到扩展样式的目的,区别在于@Extend需要依托于组件存在,而@Styles可以直接定义样式。这么看来是不是@Style更强?其实也不是,两者的作用是不同的,相对于@Extend基于组件的扩展,@Styles更适用于在同一个组件上展示不同的组件状态,最常用的比如按钮的点击效果:

scss 复制代码
@Entry
@Component
struct StyleDemo  {
  @State isEnable: boolean = true
  // 点按效果
  @Styles pressedStyles() {
    .backgroundColor("#ED6F21")
    .borderRadius(10)
    .borderStyle(BorderStyle.Dashed)
    .borderWidth(2)
    .borderColor("#33000000")
    .width(120)
    .height(30)
    .opacity(1)
  }
  // 无法点击效果
  @Styles disabledStyles() {
    .backgroundColor("#E5E5E5")
    .borderRadius(10)
    .borderStyle(BorderStyle.Solid)
    .borderWidth(2)
    .borderColor("#2a4c1919")
    .width(90)
    .height(25)
    .opacity(1)
  }
  // 平常状态
  @Styles normalStyles() {
    .backgroundColor("#0A59F7")
    .borderRadius(10)
    .borderStyle(BorderStyle.Solid)
    .borderWidth(2)
    .borderColor("#33000000")
    .width(100)
    .height(25)
    .opacity(1)
  }

  build() {
    Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center }) {
      Text("normal")
        .fontSize(14)
        .fontColor(Color.White)
        .opacity(0.5)
        .stateStyles({
          normal: this.normalStyles,
        })
        .margin({ bottom: 20 })
        .textAlign(TextAlign.Center)
      Text("pressed")
        .backgroundColor("#0A59F7")
        .borderRadius(20)
        .borderStyle(BorderStyle.Dotted)
        .borderWidth(2)
        .borderColor(Color.Red)
        .width(100)
        .height(25)
        .opacity(1)
        .fontSize(14)
        .fontColor(Color.White)
        .stateStyles({
          pressed: this.pressedStyles,
        })
        .margin({ bottom: 20 })
        .textAlign(TextAlign.Center)
      Text(this.isEnable == true ? "effective" : "disabled")
        .backgroundColor("#0A59F7")
        .borderRadius(20)
        .borderStyle(BorderStyle.Solid)
        .borderWidth(2)
        .borderColor(Color.Gray)
        .width(100)
        .height(25)
        .opacity(1)
        .fontSize(14)
        .fontColor(Color.White)
        .enabled(this.isEnable)
        .stateStyles({
          disabled: this.disabledStyles,
        })
        .textAlign(TextAlign.Center)
      Text("control disabled")
        .onClick(() => {
          this.isEnable = !this.isEnable
          console.log(`${this.isEnable}`)
        })
        .fontSize(10)
    }
    .width(350).height(300).margin(10)
  }
}

通过stateStyles()方法,我们就可以将Text组件的presseddisablednormal交互状态进行切换展示,而该方法内传入的参数即为由@Styles装饰的不同 UI 效果。可以和上面@Extend装饰的代码比较一下,很容易就能发现两者的不同。值得注意的是,@Styles装饰的 UI 样式也可以独立引用,不一定完全借助stateStyles()方法去实现展示。

**@BuilderParam**

该装饰器同样是为了扩展组件的能力而创造的,并且只能被@Builder装饰的自定义构建函数初始化。@BuilderParam@Extend装饰器一样可以扩展组件的行为,但和@Extend不同之处在于,@BuilderParam不需要额外去继承组件,直接支持在组件内部定义行为应用在其他组件当中。

scss 复制代码
@Component
struct CustomContainer {
  @Prop city: string;
  @BuilderParam changeCity: () => void

  build() {
    Column() {
      Text(this.city)
        .fontSize(40)
      this.changeCity()
    }
  }
}

@Builder function specificParam(city1: string, city2: string) {
  Column() {
    Text(city1)
      .fontSize(30)
    Text(city2)
      .fontSize(30)
      .margin({ top: 20 })
  }
}

@Entry
@Component
struct CustomContainerUser {
  @State text: string = "City"

  build() {
    Column() {
      CustomContainer({ city: this.text }) {
        Column() {
          specificParam('Hangzhou', 'Shanghai')
        }
        .backgroundColor(Color.Yellow)
        .onClick(() => {
          this.text = '杭州'
        })
      }
    }
  }
}

上述代码中,我们通过@BuilderParam定义了CustomContainer的一个行为,并且在使用该组件创建CustomContainer时,通过尾随闭包的方式将切换城市的能力修饰给了CustomContainer

接下来,我们再来看一下数据状态类型的装饰器。

**@Prop**

我们知道,最基础的@State装饰器所修饰的变量在产生更新时,会重绘其被引用的 UI,而@Prop装饰器则在此基础上,更进一步扩展了能力,它修饰的变量可以于其组件所在的父组件的变量建立单向联系,举个例子,在父组件 A 中存在@State装饰的变量a = 1,其子组件 B 中存在@Prop装饰的变量 a',则 A 中的 a 发生变化后,B 中的 a' 变量也会发生改变,并触发 UI 重绘,但反过来说,B 中的 a' 发生变化,A 中的 a 不会变化,是一个「单向」的行为。

less 复制代码
@Entry
@Component
struct ParentComponent {
  @State currentTemperature: number = 10;

  build() {
    Column() {
      Text(`可调节 ±${this.currentTemperature} 当前气温`)
      // 父组件的数据源的修改会同步给子组件
      Button(`当前气温 +1 ℃`).onClick(() => {
        this.currentTemperature += 1;
      })
      // 父组件的修改会同步给子组件
      Button(`当前气温 -1 ℃`).onClick(() => {
        this.currentTemperature -= 1;
      })

      ChildWeatherComponent({ tempLimit: this.currentTemperature, tempDiff: 2 })
    }
  }
}

@Component
struct ChildWeatherComponent {
  @Prop tempLimit: number;
  tempDiff: number = 1;

  build() {
    Column() {
      if (this.tempLimit > 0) {
        Text(`还剩 ${this.tempLimit}℃ 可调节`)
      } else {
        Text('结束调整')
      }
      // @Prop装饰的变量不会同步给父组件
      Button(`重新调整`).onClick(() => {
        this.tempLimit -= this.tempDiff;
      })
    }
    .margin({ top: 30 })
  }
}

上面代码中定义了父子两个组件,父组件将@State修饰的变量currentTemperature传递给子组件中由@Prop修饰的 tempLimit 当中,随着对currentTemperature值的加减操作,能实时更新子组件中的tempLimit值并刷新 UI ,同时通过子组件中的 Button 对tempLimit执行重新赋值,并不会更新到父组件中定义的属性变量。

**@Link**

理解了@Prop后,我们理解@Link装饰器就会更加简单,既然@Prop是状态的单向传递,但总得有一个装饰器来实现双向能力吧,所以@Link就应运而生了。稍微改动下上面@Prop的代码示例:

scss 复制代码
将
  @Prop tempLimit: number;
改为:
  @Link tempLimit: number;

===================================
  
将
  ChildWeatherComponent({ tempLimit: this.currentTemperature, tempDiff: 2 })
改为:
  ChildWeatherComponent({ tempLimit: $currentTemperature, tempDiff: 2 })

你就会看到,子组件中tempLimit的值也能同步到父组件currentTemperature中了。

@Provide@Consume

对于这组装饰器,从字面上很容易了解是典型的「生产者-消费者」模式,「一对多」的关系,由父组件提供一个由@Provide修饰的数据状态,那么各个子组件中就可以通过@Consume装饰器去同步父组件提供的状态,同时这组装饰器还支持跨组件双向 传递,没错,这组装饰器是双向的,类似于@Link的能力。这里双向的同步能力,其实和典型的生产者-消费者模型是有区别的,消费者在消费数据的同时也能去修改生产者的数据,这在代码稳定性上容易造成数据/状态错乱的风险,因此,在不清楚 HarmonyOS 为什么这么设计之前,请慎用该组装饰器。

scss 复制代码
@Component
struct Shangcheng {
  @Consume temperature: number;

  build() {
    Column() {
      Text(`上城区气温:${this.temperature} ℃`)
      Button(`设置上城区气温:${this.temperature}`)
        .onClick(() => this.temperature += 1)
    }
    .width('50%')
  }
}


@Component
struct Binjiang {
  // @Consume装饰的变量通过相同的属性名绑定其祖先组件CompA内的@Provide装饰的变量
  @Consume temperature: number;

  build() {
    Column() {
      Text(`滨江区气温:${this.temperature} ℃`)
      Button(`设置滨江区气温:${this.temperature}`)
        .onClick(() => this.temperature += 1)
    }
    .width('50%')
  }
}

@Component
struct Downtown {
  build() {
    Row({ space: 5 }) {
      Shangcheng()
      Binjiang()
    }
  }
}

@Component
struct Hangzhou {
  build() {
    Downtown()
  }
}

@Entry
@Component
struct Zhejiang {
  @Provide temperature: number = 0;

  build() {
    Column() {
      Button(`Set ${this.temperature} of current area`)
        .onClick(() => this.temperature += 1)
      Hangzhou()
    }
  }
}

上述代码中,通过@Provide装饰器我们可以跨组件将状态传递给其他下游组件树中的组件,同时下游子组件也能不断向上同步影响到最上游的父组件状态,从代码的例子中可以看到,这样做容易造成数据混乱,例如浙江的气温可以大致代表下辖各地的气温,但是将某一地(比如滨江区)的气温同步回浙江省,那显然是不合适的。

在前面介绍的装饰器中,我们有意忽略了一个关键点,就是这些装饰器能支持的状态类型有哪些限制?是的,装饰器并不能支持所有的状态类型,有些甚至只能支持基础的像 numberstringboolean 等类型(装饰器支持的类型我们统一罗列到下表当中用以对比):

装饰器 @State @Prop @Link @Provide/@Comsume @Observed/@ObjectLink
支持装饰的变量类型 Object、class、string、number、boolean、enum类型,以及这些类型的数组 string、number、boolean、enum Object、class、string、number、boolean、enum以及这些类型的数组 Object、class、string、number、boolean、enum类型,以及这些类型的数组 @Observed 装饰class,需要放在class定义前,使用new创建类对象 @ObjectLink 必须为被@Observed装饰的class实例,必须指定类型。不支持简单类型,可以使用@Prop。
同步类型 不与父组件中任何类型的变量同步 单向同步 双向同步 双向同步 不与父组件中任何类型的变量同步

为了解决上述的装饰器只能观察第一层变化(可以理解为「浅拷贝」)的问题,HarmonyOS 提供了@Observed@ObjectLink这组装饰器来解决深层次的状态监听问题(可以理解为「深拷贝」)。

前面提到的@StatePropLink数据状态装饰器都只能在页面内,即一个组件树上共享状态变量,如果要实现应用级,或者多个页面状态的数据共享,就需要用到LocalStorageAppStorage相关的对象,同时后续提到的@StorageProp@LocalStorageProp@StorageLink@LocalStorageLink装饰器都是为了实现应用以及页面间的状态共享而使用的。这两组组装饰器,解决了应用全局的 UI 状态存储以及页面间的状态共享问题,@StorageProp@StorageLink这组装饰器是和AppStorageAPI 做绑定使用的,同时既然是应用级别的,自然也可以理解为和「进程」也是绑定的。同时通过后缀"xxxProp"和"xxxLink"可以区分是单向还是双向同步。对于 UI 类型的应用级全局状态管理,就需要使用这组装饰器。由于两对装饰器非常相似,我们直接通过代码来进一步了解这两对装饰器:

typescript 复制代码
AppStorage.SetOrCreate('LocalCityCache', 'Hangzhou');
let storage = new LocalStorage({ 'LocalWeatherCache': 'Sunny' });

@Entry(storage)
@Component
struct WeatherReport {
  @StorageLink('LocalCityCache') cityCacheLink: string = 'Shanghai';
  @LocalStorageLink('LocalWeatherCache') localWeatherLink: string = 'Cloudy';

  build() {
    Column({ space: 20 }) {
      Text(`AppStorage City: ${this.cityCacheLink}`)
        .onClick(() => this.cityCacheLink = 'Jiangsu')

      Text(`LocalStorage Weather: ${this.localWeatherLink}`)
        .onClick(() => this.localWeatherLink = 'Rain')
    }
  }
}

因为这两组标签涉及到不同页面或者 UIAbility 间的状态交互,这里只给了一个简单的代码来表明大致的用法,后面我们会在实战篇中去具体展开运用。但通过这个简单的代码片段,我们也可以看出一些逻辑,当这个组件被初始化时,显示的并不是组件内部定义的默认值"Shanghai"和"Cloudy",而是直接取了AppStorageLocalStorage中的初始值,相当于被这两组装饰器装饰后的变量会先去读缓存中的值。

**@Watch**

@Watch装饰器也是对状态变量的监听,和上述状态装饰器不同的是,它修饰的是函数,通过回调方法去监听状态的变化。相对而言,实现也很简单:

typescript 复制代码
import hilog from '@ohos.hilog';

@Component
struct WeatherView {
  @Prop @Watch('onWeatherUpdated') weather: string;
  @State currentWeather: string = '';
  // @Watch cb
  onWeatherUpdated(weather: string): void {
    this.currentWeather = this.weather;
  }

  build() {
    Text(`当前天气: ${this.currentWeather}`)
  }
}

@Entry
@Component
struct WeatherRefresher {
  @State weatherList: Array<string> = ["sunny", "cloudy", "rainy", "snowy", "hazy", "foggy", "overcast"];
  @State index: number = 0;
  @State weather: string = 'sunny';

  build() {
    Column() {
      Button('刷新天气')
        .onClick(() => {
          if (this.index < this.weatherList.length) {
            this.weather = this.weatherList[this.index];
            this.index++
          }
        })
      WeatherView({
        weather: this.weather
      })
    }
  }
}

定义一个回调函数,同时给回调函数又装饰了weather变量,通过父组件的变量刷新,将值回调到onWeatherUpdated方法中,并引起子组件的重绘。

UIAbility

前面提到了和 UI 相关的组件和涉及到组件状态刷新的装饰器,我们再来看看具体的承载组件的页面是怎么开发的。

UIAbility 也是一种包含 UI 界面的应用组件,但和通常组件有区别的点在于,它是系统调度的基本单元,一个 UIAbility 组件可以通过多个页面实现一个功能模块,而每一个组件实例都对应于一个最近任务列表中的任务。涉及到页面,必然会关联到「生命周期管理」,开发者需要在每个关键的生命周期节点去做相应的业务行为逻辑,UIAbility 和 Android 中的 Activity 也非常类似,包含了从创建到可见再到后台不可见以及最后销毁的关键节点:

从上面的图中,我们可以看到两个关键对象:UIAbility 和 WindowStage,WindowStage 即对应了前两篇提到的 Stage 开发模型,每个 UIAbility 类实例都会与一个 WindowStage 类实例进行绑定,该类提供了应用进程内窗口管理器的作用,包含了一个主窗口,该窗口即为 ArkUI 提供了绘制区域。

php 复制代码
/**
     * WindowStage
     * @syscap SystemCapability.WindowManager.WindowManager.Core
     * @since 9
     */
    interface WindowStage {
        /**
         * Get main window of the stage.
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        getMainWindow(): Promise<Window>;
        /**
         * Get main window of the stage.
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        getMainWindow(callback: AsyncCallback<Window>): void;
        /**
         * Get main window of the stage.
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        getMainWindowSync(): Window;
        /**
         * Create sub window of the stage.
         * @param name window name of sub window
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        createSubWindow(name: string): Promise<Window>;
        /**
         * Create sub window of the stage.
         * @param name window name of sub window
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        createSubWindow(name: string, callback: AsyncCallback<Window>): void;
        /**
         * Get sub window of the stage.
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        getSubWindow(): Promise<Array<Window>>;
        /**
         * Get sub window of the stage.
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        getSubWindow(callback: AsyncCallback<Array<Window>>): void;
        /**
         * Loads content
         * @param path  path Path of the page to which the content will be loaded
         * @param storage storage The data object shared within the content instance loaded by the window
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @syscap SystemCapability.WindowManager.WindowManager.Core
         * @since 9
         * @StageModelOnly
         */
        loadContent(path: string, storage: LocalStorage, callback: AsyncCallback<void>): void;
        /**
         * Loads content
         * @param path path of the page to which the content will be loaded
         * @param storage storage The data object shared within the content instance loaded by the window
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @syscap SystemCapability.WindowManager.WindowManager.Core
         * @since 9
         * @StageModelOnly
         */
        loadContent(path: string, storage?: LocalStorage): Promise<void>;
        /**
         * Loads content
         * @param path path of the page to which the content will be loaded
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @syscap SystemCapability.WindowManager.WindowManager.Core
         * @since 9
         * @StageModelOnly
         */
        loadContent(path: string, callback: AsyncCallback<void>): void;
        /**
         * Window stage event callback on.
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        on(eventType: 'windowStageEvent', callback: Callback<WindowStageEventType>): void;
        /**
         * Window stage event callback off.
         * @throws {BusinessError} 401 - If param is invalid
         * @throws {BusinessError} 1300002 - If window state is abnormally
         * @throws {BusinessError} 1300005 - If window stage is abnormally
         * @since 9
         * @StageModelOnly
         */
        off(eventType: 'windowStageEvent', callback?: Callback<WindowStageEventType>): void;
    }

扫一眼WindowStage的定义,发现它是一个接口,具备获取各层级窗口、创建子窗口和加载内容的能力,而深入接口实现的源码没有找到,暂时无法继续深入分析。可以看到的是,通过接口解耦的方式,分隔了 UI 实际页面和窗口之间的关系,通过形参传递的方式,WindowStage 作为入参放到了 UIAbility 的生命周期回调中,使 UIAbility 和 WindowStage 通过更加解耦的方式关联起来。

php 复制代码
export default class AbilityLifecycleCallback {
    /**
     * Called back when an ability is started for initialization.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onAbilityCreate(ability: UIAbility): void;
    /**
     * Called back when a window stage is created.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @param { window.WindowStage } windowStage - window stage to create
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onWindowStageCreate(ability: UIAbility, windowStage: window.WindowStage): void;
    /**
     * Called back when a window stage is actived.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @param { window.WindowStage } windowStage - window stage to active
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onWindowStageActive(ability: UIAbility, windowStage: window.WindowStage): void;
    /**
     * Called back when a window stage is inactived.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @param { window.WindowStage } windowStage - window stage to inactive
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onWindowStageInactive(ability: UIAbility, windowStage: window.WindowStage): void;
    /**
     * Called back when a window stage is destroyed.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @param { window.WindowStage } windowStage - window stage to destroy
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onWindowStageDestroy(ability: UIAbility, windowStage: window.WindowStage): void;
    /**
     * Called back when an ability is destroyed.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onAbilityDestroy(ability: UIAbility): void;
    /**
     * Called back when the state of an ability changes to foreground.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onAbilityForeground(ability: UIAbility): void;
    /**
     * Called back when the state of an ability changes to background.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onAbilityBackground(ability: UIAbility): void;
    /**
     * Called back when an ability prepares to continue.
     * @param { Ability } ability - Indicates the ability to register for listening.
     * @syscap SystemCapability.Ability.AbilityRuntime.AbilityCore
     * @StageModelOnly
     * @since 9
     */
    onAbilityContinue(ability: UIAbility): void;
}

生命周期回调函数的作用和用法很简单,这里不再赘述,这里再介绍下 UIAbility 的三种启动模式:

  • singleton,单实例模式(默认启动模式),每次调用startAbility()方法时,如果应用进程中该类型的 UIAbility 实例已经存在,则复用系统中的 UIAbility 实例。系统中只存在唯一一个该UIAbility实例,和 Android 中的singleInstance启动类型类似。使用该模式需要额外注意,既然是复用,在已存在的前提下,再次唤起就不会再去回调onCreate()onWindowStageCreate()方法。

  • standard,标准实例模式,通过startAbility()唤起时,都会在同一个进程中创建一个新的 UIAbility 实例,也可以在设备后台的最近任务列表中看到多个创建实例。

  • specified,指定实例模式,简单理解,这种模式通过指定打开的 UIAbility 对应键值的方式,实现同一个 UIAbility 不同行为的能力,当 key 对应的 UIAbility 是之前已经创建完成的状态,则不走onCreate()onWindowStageCreate(),而是回调onAcceptWant(),当发现该 key 对应的 UIAbility 没有被创建,则根据正常创建生命周期完成页面加载。该模式的主要应用场景,主要用于同入口但不同页面内容显示效果的场景,比如同一个 UIAbility 下的文档创建(onCreate())/文档重新打开(onAcceptWant)两种不同的展示行为。

和 Android 设计类似的是,UIAbility 也存在 Context(上下文)与之相关联,HarmonyOS 中称为UIAbilityContext。通过上下文,开发者可以获取到基本的环境数据,具体看下源码:

php 复制代码
export default class UIAbilityContext extends Context {
    
    abilityInfo: AbilityInfo;
    
    currentHapModuleInfo: HapModuleInfo;
    
    config: Configuration;
   
    startAbility(want: Want, callback: AsyncCallback<void>): void;
   
    startAbility(want: Want, options: StartOptions, callback: AsyncCallback<void>): void;
  
    startAbility(want: Want, options?: StartOptions): Promise<void>;
    
    startAbilityByCall(want: Want): Promise<Caller>;
   
    startAbilityForResult(want: Want, callback: AsyncCallback<AbilityResult>): void;
    
    startAbilityForResult(want: Want, options: StartOptions, callback: AsyncCallback<AbilityResult>): void;
   
    startAbilityForResult(want: Want, options?: StartOptions): Promise<AbilityResult>;
  
    terminateSelf(callback: AsyncCallback<void>): void;
   
    terminateSelf(): Promise<void>;
    
    terminateSelfWithResult(parameter: AbilityResult, callback: AsyncCallback<void>): void;
    
    terminateSelfWithResult(parameter: AbilityResult): Promise<void>;
    
    connectServiceExtensionAbility(want: Want, options: ConnectOptions): number;
   
    disconnectServiceExtensionAbility(connection: number, callback: AsyncCallback<void>): void;
    
    disconnectServiceExtensionAbility(connection: number): Promise<void>;
    
    setMissionLabel(label: string, callback: AsyncCallback<void>): void;
   
    setMissionLabel(label: string): Promise<void>;
   
    restoreWindowStage(localStorage: LocalStorage): void;
   
    isTerminating(): boolean;
    
    requestDialogService(want: Want, result: AsyncCallback<dialogRequest.RequestResult>): void;
    
    requestDialogService(want: Want): Promise<dialogRequest.RequestResult>;
}

从源码中可以看到UIAbilityContext继承了Context(Context 持有了资源管理器、应用信息、存储路径等实例),从开发过程中能获取到的环境信息abilityInfocurrentHapModuleInfo有:包名、模块名、标签、进程名、图标 ID、方向、权限、屏幕尺寸等,有区分了 UIAbility 和 Hap Module 两个维度。从其提供的方法看,主要提供了获取信息、启动 Ability、销毁 Ability 等关键能力。

再来看一下 UIAbility 组件交互部分,这里交互指:应用内(同进程)启动、应用内启动并回调结果、应用外(不同进程)启动、应用外启动并回调结果、启动指定页面。由于调用方式都较为类似,我们通过代码只比较下较为复杂的带回调结果的代码实现:

typescript 复制代码
/*****************启动新的 UIAbility*********************/
let wantInfo = {
    deviceId: '', // deviceId为空表示本设备
    bundleName: 'com.demo.weathertest',
    abilityName: 'WeatherDetailAbility',
    moduleName: 'main', // moduleName非必选
    parameters: { // 自定义信息
        info: 'data from this UIAbility',
    },
}
// context为调用方UIAbility的AbilityContext
this.context.startAbilityForResult(wantInfo).then((data) => {
}).catch((err) => {
})

const RESULT_CODE: number = 1001;

/*****************监听从新的 UIAbility 回调回来的数据*********************/
// context为调用方UIAbility的AbilityContext
this.context.startAbilityForResult(want).then((data) => {
    if (data?.resultCode === RESULT_CODE) {
        // 解析被调用方 WeatherDetailAbility 返回的信息
        let info = data.want?.parameters?.info;
    }
}).catch((err) => {
})

/*****************传回从 WeatherDetailAbility 回调回来的数据*********************/
const RESULT_CODE: number = 1001;
let abilityResult = {
    resultCode: RESULT_CODE,
    want: {
        bundleName: 'com.demo.weathertest',
        abilityName: 'WeatherDetailAbility',
        moduleName: 'main',
        parameters: {
            info: 'data from WeatherDetailAbility',
        },
    },
}
// context为被调用方UIAbility的AbilityContext
this.context.terminateSelfWithResult(abilityResult, (err) => {
    // ...
});

有经验的开发者通过上述代码可以分析到,不同进程的 UIAbility 其实就是 wantInfo 信息使用其他进程中的 Ability 信息就可以实现,当然 HarmonyOS 同样提供了除了上述显示实现外的隐式调用,感兴趣的可以自己实践一下。整体下来,你会发现:和 Android 极其相似!

这里再额外介绍一种通过 Call 调用实现 UIAbility 的交互方式,由于该方式仅对系统应用开放,因此开发者可以稍作了解,有个印象即可。Call 调用可以在调用方与被调用方间建立 IPC 通信,从而实现不同 UIAbility 之间的数据共享。并且 Call 还支持后台拉起,即不需要要将 UIAbility 展示到前台也可以被创建运行。

Native 开发

我们再来看一下较有难度的 Native 开发,为什么要将这一节提到前面介绍,因为笔者认为,除了面向用户的 UI 交互部分之外,其他都可以通过 HarmonyOS 提供的官方 API 去实现相应的能力,而 Native 开发不同,首先,这部分的知识是「跨语言」的,除了要具备 JS/TS 语言基础之上,还需要对 C/C++、Make、工具链等知识储备。同时 Native 开发在性能方面有着官方 ArkTS 无法具备的优势,一款成熟的应用不管有没有 UI,都绕不开算法、并行计算、性能方面的考量,如果说 UI 是表面,则 Native 实现的部分则是里层和核心,因此在本篇中,将 Native 开发提到了第二重要优先级。

同 Java 的 JNI 相同,HarmonyOS 实现 Native 代码来开发时,也必须经过相应的桥接层来实现上层代码和 Native 之间的逻辑调用。有着 Java Native 开发经验的同学会非常快速的掌握 HarmonyOS Native 的开发,因为除了 NAPI 部分(对应 Java 的 JNI),其他几乎是一摸一样的开发方式。以下是相同点:

  • 构建同样使用 CMakeList.txt 作为构建执行

  • 使用napi_env代替原来的 JVM 环境

  • 同样需要在根目录的配置文件中定义 CMakeList.txt 文件的路径(同 build.gradle 类似):

    "buildOption": { "externalNativeOptions": { "path": "./src/main/cpp/CMakeLists.txt", "arguments": "", "cppFlags": "", } }

我们重点来看以下不一样的部分,核心要解决的一个点是「如何去定义一份 NAPI 入口代码」。

在工程上,选中 main 文件夹后右键 New 一个带 NAPI 的 C++ 文件,工程就会自动创建一个标准样式的开发目录,生成 CMakeList.txt 文件的同时,将其配置到工程根目录下的 build-profile.json5 文件中,同时在 cpp 文件夹下生成对应的 NAPI 代码文件接口:

types 文件夹下生成的即为 napi 的桥接层代码,包含一个 ts 文件和一个 json5 配置文件。配置文件中默认定义了 so 算法库的名称、napi 文件路径、版本号和描述。重点是 index.d.ts 文件,该文件对应于 JNI 的 native.java 接口类,通过export将需要暴露的 Native 函数的提供给 TS 调用,而将具体逻辑对外部调用方隐藏:

typescript 复制代码
export const add: (a: number, b: number) => number;

而对于具体函数的实现部分,首先通过napi_module_register进行模块注册,定义模块名称、注册的函数等信息:

ini 复制代码
static napi_module demoModule = {
    .nm_version =1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void*)0),
    .reserved = { 0 },
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void)
{
    napi_module_register(&demoModule);
}

通过宏定义EXTERN_C_START/EXTERN_C_END定义在nm_register_func中的函数入口Init,用napi_property_descriptor结构体定义具体的函数名、参数、返回值和回调等信息:

ini 复制代码
typedef struct {
  // One of utf8name or name should be NULL.
  const char* utf8name;
  napi_value name;

  napi_callback method;
  napi_callback getter;
  napi_callback setter;
  napi_value value;

  napi_property_attributes attributes;
  void* data;
} napi_property_descriptor;

最后完成函数的定义:

arduino 复制代码
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports)
{
    napi_property_descriptor desc[] = {
        { "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr }
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

上述的基本实现,都需要依赖napi的头文件:

arduino 复制代码
#include "napi/native_api.h"

还有更多的 napi 调用我们会在实战篇中详述。

媒体开发

温馨提示:到目前为止(20240105),HarmonyOS 关于 camera 相机部分的 API 在 SDK API 9 的版本并没有开放出来,也就是说当前想要开发相机的应用是做不到的。而 camera 相关的源码和 API 使用已经在 Open HarmonyOS 中释出,可以将 Eco-Dev Studio 切换成 OpenHarmony SDK 进行开发和体验。但是没法做到模拟器或者真机进行调试(因为只能通过 OS repo 编译后的系统才能使用)。
笔者问了相关鸿蒙官方开发,消息称 camera 的 API 将在 24 年 Q1 季度全社会面开放。

多媒体数据的处理,历来都是各个系统中较为复杂的实现部分,由于和麦克风、相机、编解码硬件等间接打交道,所以要通过较为复杂的比如参数配置、状态机处理、napi Native 实现等操作配合来完成一个成熟的多媒体能力。

这一节对于音视频处理和播放以及图片不做展开,原因在于和后续要实现的业务不是那么强相关,实现上还停留在表面,没精力去做深入探索。所以这一节的重点在于 HarmonyOS 相机能力的实现。

受到相关开发经历的影响,笔者在相机开发这一层有较为深入的理解,结合 HarmonyOS 来看看该系统对于相机的能力有没有自己精巧的设计。

一个小坑

这里需要提前记录一个小坑,我们通过 DevEco Studio 创建一个新的工程默认是 HarmonyOS,而我们将要使用的@ohos.multimedia.camera关于 camera 的 API 是在 OpenHarmonyOS 当中的,两者的区别在前面的《工具篇》已经有过介绍,因此对于一个默认的 HarmonyOS 工程,通过 ArkTS 是实现不了鸿蒙相机能力的,而且 HarmonyOS 当前(20231226)最高的版本是 9,而 camera 的 API 是在 OpenHarmonyOS 的 API 10 中,所以,如果要实现 camera,必须要调整工程的配置。(吐槽一句:没理解为什么要这么设计,即使 Harmony 和 OpenHarmony 有联系,也应该有独立并且完整的硬件能力才对)

为了要使用 camera 的 API,我们需要做以下调整:

  1. 下载 OpenHarmonyOS 的 SDK 版本[入口:Tools -> SDK Manager ],选择 OpenHarmony 的选项,并触发下载:
  1. 更改工程配置环境,在应用级别的build-profile.json5文件中,将默认的 xxxSdkVersion 从 9 改到 10,并且重新指定runtimeOSOpenHarmony

    { ... "app": { "signingConfigs": [], "products": [ { "name": "default", "signingConfig": "default", "runtimeOS": "OpenHarmony", "compileSdkVersion": 10, "compatibleSdkVersion": 10, "targetSdkVersion": 10 } ] } ... }

通过上述变更,我们就能正常使用 camera 的 API 了。

值得注意的是,更改了工程环境之后,会出现之前在 HarmonyOS 环境下不会编译报错的一系列问题,这些报错问题解决起来不是很困难,不再这里提及。目前遇到的一个棘手问题是,更改了环境之后的工程无法在普通鸿蒙真机设备上运行。
再多嘴一句,工程环境变化后的报错很多都是由 TS 和 ArkTS 之间的语法兼容问题导致的,开发者可以在 IDE 中查看具体错误类型,并在这篇文章中找到对应的修改方式

相机的基本实现

这一节我们介绍下通过 ArkTS 去实现一个基础的相机能力。同 Android 一样,HarmonyOS 中相机的实现也依赖于 camera.CameraManager类,和 Android Camera1 的 API 大同小异:

  1. 获取CameraManager管理器,通过管理器拿到相机镜头、创建输出流、预览流等逻辑

  2. 通过媒体类image创建一个输出流的接收器,用于接受相机的输出帧数据

  3. 设置相机分辨率、角度、闪光灯等等相机参数

  4. 通过camera.CaptureSession输出预览画面

  5. 释放相机

我们通过上述流程来熟悉下具体代码:

kotlin 复制代码
 async initCamera(surfaceId: string): Promise<void> {
    // 初始化前先释放相机,避免出现状态机的状态异常,引起初始化失败
    await this.cameraRelease();
    if (deviceInfo.deviceType === 'default') {
      this.videoSourceType = 1;
    } else {
      this.videoSourceType = 0;
    }
    try {
      this.cameraMgr = camera.getCameraManager(globalThis.cameraContext);
    } catch (e) {
    }
    // 获取相机镜头
    this.camerasArray = this.cameraMgr.getSupportedCameras();
    if (this.camerasArray.length === 0) {
      Logger.info(TAG, 'cannot get cameras');
      return;
    }

    let mCamera = this.camerasArray[0];
    this.cameraInput = this.cameraMgr.createCameraInput(mCamera);
   // 打开相机
    this.cameraInput.open();
   // 获取支持的相机调整参数能力
    this.capability = this.cameraMgr.getSupportedOutputCapability(mCamera);
    let previewProfile = this.capability.previewProfiles[0];
    this.previewOutput = this.cameraMgr.createPreviewOutput(
      previewProfile,
      surfaceId
    );
    // 创建输出流
    let rSurfaceId = await this.receiver.getReceivingSurfaceId();
    let photoProfile = this.capability.photoProfiles[0];
    this.photoOutPut = this.cameraMgr.createPhotoOutput(
      photoProfile,
      rSurfaceId
    );
   // 创建相机session之后定义输入(相机)和输出(预览和照片)
    this.capSession = this.cameraMgr.createCaptureSession();
    this.capSession.beginConfig();
    this.capSession.addInput(this.cameraInput);
    this.capSession.addOutput(this.previewOutput);
    this.capSession.addOutput(this.photoOutPut);
    await this.capSession.commitConfig();
    await this.capSession.start();
  }

创建图像输出流:

ini 复制代码
  this.mediaModel = MediaModel.getMediaInstance();
    this.receiver = image.createImageReceiver(
      cameraWH.width,
      cameraWH.height,
      FOUR,
      EIGHT
    );
    this.receiver.on('imageArrival', () => {
      this.receiver.readNextImage((err, image) => {
        if (err || image === undefined) {
          Logger.error(TAG, 'failed to get valid image');
          return;
        }
        // 定义输出的图像流格式 FOUR 代表 JPEG 格式
        image.getComponent(FOUR, (errMsg, img) => {
          Logger.info(TAG, 'getComponent');
          if (errMsg || img === undefined) {
            return;
          }
          let buffer = new ArrayBuffer(4096);
          if (img.byteBuffer) {
            buffer = img.byteBuffer;
          } else {
            Logger.error(TAG, 'img.byteBuffer is undefined');
          }
          this.saveImage(buffer, image);
        });
      });
    });

释放相机及相关输出流:

kotlin 复制代码
 async cameraRelease(): Promise<void> {
    if (this.cameraInput) {
      await this.cameraInput.close();
    }
    if (this.previewOutput) {
      await this.previewOutput.release();
    }
    if (this.photoOutput) {
      await this.photoOutput.release();
    }
    if (this.capSession) {
      await this.capSession.release();
    }
  }

代码中的cameraInputcamera.CameraInput)、previewOutputcamera.PreviewOutput)、photoOutputcamera.PhotoOutput)、capSessioncamera.CaptureSession)基本可以代表整个相机使用过程中需要用到的类对象,包括相机成片、预览输出流、相机输入流以及流管理。

值得一提的是,HarmonyOS 中的 camera 也提供了改变输出帧格式的 API,当前相机流的输出格式支持以下三种:

php 复制代码
 enum CameraFormat {
        /**
         * RGBA 8888 Format.
         *
         * @syscap SystemCapability.Multimedia.Camera.Core
         * @since 10
         */
        CAMERA_FORMAT_RGBA_8888 = 3,
        /**
         * YUV 420 Format.
         *
         * @syscap SystemCapability.Multimedia.Camera.Core
         * @since 10
         */
        CAMERA_FORMAT_YUV_420_SP = 1003,
        /**
         * JPEG Format.
         *
         * @syscap SystemCapability.Multimedia.Camera.Core
         * @since 10
         */
        CAMERA_FORMAT_JPEG = 2000
    }

从以上代码示例可以看到,表面上的相机实现和 Android 大同小异,要想看到一些不同,需要实际跑在真机上才能看出一些差异点,期待这份 API 能尽早上线。

Web 开发

由于 HarmonyOS 本身是基于 TS 开发的,所以天然具备 Web 开发的属性,在module.json5中配置了ohos.permission.INTERNET网络访问权限后,我们就可以通过WebviewController类实现基本的 URL 加载(当然也可以加载本地路径文件):

typescript 复制代码
import web_webview from '@ohos.web.webview'

@Entry
@Component
struct WebComponent {
  webviewController: web_webview.WebviewController = new web_webview.WebviewController()

  build() {
    Column() {
      Button('loadUrl')
        .onClick(() => {
          // 加载本地文件路径
          // this.webviewController.loadUrl($rawfile("local.html"));
          this.webviewController.loadUrl('www.baidu.com');
        })
      Web({
        // src: $rawfile('local.html')
        src: 'www.baidu.com',
        controller: this.webviewController
      })
    }
  }
}

相较于 URL 加载这类基操,我们更关系 JavaScript 是怎么用的。同样非常简单,组件调用前端方法通过WebviewControllerrunJavaScript()方法,前端调用组件中定义的方法,可以使用javaScriptProxy()(同步)或者registerJavaScriptProxy()(异步,注册后需调用refresh()接口生效)两种方式。

typescript 复制代码
import web_webview from '@ohos.web.webview'

@Entry
@Component
struct WebComponent {
  webviewController: web_webview.WebviewController = new web_webview.WebviewController()
  @State testObj: TestClass = new TestClass();

  build() {
    Column() {
      Button('loadUrl')
        .onClick(() => {
          // APP 调用前端页面 index.html 中定义的 htmlTest() 方法
          this.webviewController.runJavaScript('htmlTest()');
        })
      Web({
        src: $rawfile('index.html'),
        controller: this.webviewController
      }).javaScriptProxy({
        object: this.testObj,
        name: "testObjectName",
        methodList: ["test"],
        controller: this.webviewController
      })
    }
  }
}

class TestClass {
  constructor() {
  }

  test(): string {
    return 'JavaScript test execute';
  }
}

<!-- index.html -->
<!DOCTYPE html>

<html>
<body>
<button type="button" onclick="callArkTs()">Click</button>
<p id="demo"></p>
<script>
    function htmlTest(){
        console.info('JavaScript running');
    }

    function callArcTs(){
      let str = objName.test();
      document.getElementById("demo").innerHTML = str;
      console.info('JavaScript active: '+str);
    }

</script>
</body>
</html>

前端页面还可以通过crateWebMessagePorts()接口创建和应用侧之间的双端通信,由于也是模版化的操作,这里不再赘述。

数据管理

对于 Android 应用开发而言,存储方式大致有以下几种:内存、SharedPreferences(本质是 xml 文件)、SQLite、文件等,同样,HarmonyOS 基于其分布式的特点,也重新定义了几种数据管理方式:

除了分布式数据对象和跨应用数据管理这两类偏向于系统和硬件的数据管理之外,应用内常用的是 Preferences 和数据库(又分为键值型和关系型)。三者主要的应用场景有:

  • Preferences(用户首选项):用于保存应用的配置信息,数据通过文本形式保存在设备中,使用时全量加载,速度快、效率高,但不适合大量数据存储的场景

  • KV-Store(键值型数据库):非关系型数据库,以键值对的形式进行组织、索引和存储,由于"键"作为其唯一的标识符,在不复杂的关系业务数据存储中,因为其很少产生版本兼容和数据冲突问题,被跨设备跨版本的业务需求广泛使用

  • RelationalStore(关系型数据库),常用的关系型 SQLite 数据库使用,不做太多介绍。

从上面架构分层可以看出,HarmonyOS 其实和 Android 实现没有本质区别,都是基于 XML 和 SQLite 的方式去实现数据持久化,甚至在接口定义上都非常类似。

网络连接

网络能力作为任何应用最基础的能力之一,本篇也做一些简单的介绍,其实对于存储(文件管理)、网络部分等内容基本上任何系统都会有一套比较统一的范式,无非是上层的接口调用每个系统不同而已,底层是类似的,因此后面几节都会比较简单概述一下。

对于 HarmonyOS 系统而言,有 HTTP、WebSocket以及 Socket 三种网络链接方式,而应用要使用网络连接的能力也必须声明ohos.permission.GET_NETWORK_INFOohos.permission.SET_NETWORK_INFOohos.permission.INTERNET三个权限(建议三个一起申请,一般都会用到)。

HTTP

和任何系统类似,HTTP 支持 GET、POST、PUT、DELETE、OPTIONS、HEAD、TRACE、CONNECT 方式,无论那种方式,实现的范式都是一致的:

  1. 导入 http 命名空间@ohos.net.http.d.ts

  2. 通过createHttp()方法创建HttpRequest对象

  3. 用该对象的on()方法订阅响应头事件,可执行一些前置的头数据业务判断(非必需)

  4. 传入请求 UR L以及入参,调用该对象的request()方法发起网络请求

  5. 解析返回结果

  6. 调用off()方法取消订阅请求响应头事件(和步骤3对应)

  7. 在请求完成时调用destroy()方法销毁释放资源

来看具体的代码实现:

php 复制代码
export interface HttpRequest {
       
        request(url: string, callback: AsyncCallback<HttpResponse>): void;
        request(url: string, options: HttpRequestOptions, callback: AsyncCallback<HttpResponse>): void;
        request(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
        /**
         * Destroys an HTTP request.
         */
        destroy(): void;
        /**
         * Registers an observer for HTTP Response Header events.
         *
         * @deprecated since 8
         * @useinstead on_headersReceive
         */
        on(type: "headerReceive", callback: AsyncCallback<Object>): void;
        /**
         * Unregisters the observer for HTTP Response Header events.
         *
         * @deprecated since 8
         * @useinstead off_headersReceive
         */
        off(type: "headerReceive", callback?: AsyncCallback<Object>): void;
        /**
         * Registers an observer for HTTP Response Header events.
         *
         * @since 8
         */
        on(type: "headersReceive", callback: Callback<Object>): void;
        /**
         * Unregisters the observer for HTTP Response Header events.
         *
         * @since 8
         */
        off(type: "headersReceive", callback?: Callback<Object>): void;
        /**
         * Registers a one-time observer for HTTP Response Header events.
         *
         * @since 8
         */
        once(type: "headersReceive", callback: Callback<Object>): void;
    }

作为HttpRequest的接口实现,对外的能力接口非常简洁,在上面的实现流程中也都有提及(官方的代码示例写的很清晰,这里借用):

javascript 复制代码
// 引入包名
import http from '@ohos.net.http';

// 每一个httpRequest对应一个HTTP请求任务,不可复用
let httpRequest = http.createHttp();
// 用于订阅HTTP响应头,此接口会比request请求先返回。可以根据业务需要订阅此消息
// 从API 8开始,使用on('headersReceive', Callback)替代on('headerReceive', AsyncCallback)。 8+
httpRequest.on('headersReceive', (header) => {
    console.info('header: ' + JSON.stringify(header));
});
httpRequest.request(
    // 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
    "EXAMPLE_URL",
    {
        method: http.RequestMethod.POST, // 可选,默认为http.RequestMethod.GET
        // 开发者根据自身业务需要添加header字段
        header: {
            'Content-Type': 'application/json'
        },
        // 当使用POST请求时此字段用于传递内容
        extraData: {
            "data": "data to send",
        },
        expectDataType: http.HttpDataType.STRING, // 可选,指定返回数据的类型
        usingCache: true, // 可选,默认为true
        priority: 1, // 可选,默认为1
        connectTimeout: 60000, // 可选,默认为60000ms
        readTimeout: 60000, // 可选,默认为60000ms
        usingProtocol: http.HttpProtocol.HTTP1_1, // 可选,协议类型默认值由系统自动指定
    }, (err, data) => {
        if (!err) {
            // data.result为HTTP响应内容,可根据业务需要进行解析
            console.info('Result:' + JSON.stringify(data.result));
            console.info('code:' + JSON.stringify(data.responseCode));
            // data.header为HTTP响应头,可根据业务需要进行解析
            console.info('header:' + JSON.stringify(data.header));
            console.info('cookies:' + JSON.stringify(data.cookies)); // 8+
        } else {
            console.info('error:' + JSON.stringify(err));
            // 取消订阅HTTP响应头事件
            httpRequest.off('headersReceive');
            // 当该请求使用完毕时,调用destroy方法主动销毁
            httpRequest.destroy();
        }
    }
);

WebSocket 和 Socket

这里将 WebSocket 和 Socket 放在一起讲,需要澄清的是,两者完全是两个不同的通信机制,但在 HarmonyOS 的实现上,为了统一,从对外实现 API 上非常的类似,所以放在一起会更精简和容易理解。为了读者更加明确两者的区别,这里再多说几句,Socket 是底层基于 TCP 和 UDP 协议的通用网络通信协议,相较于 WebSocket 更加底层和灵活,而 WebSocket 特别为浏览器和服务器间的通信而设计,基于单个 TCP 连接上的全双工通信协议,所以在层级上,两者就处于不同的层结构中。

和 Http 类似,WebSocket 和 Socket 都首先需要创建相关的请求实例:

javascript 复制代码
// WebSocket
import webSocket from '@ohos.net.webSocket';
let ws = webSocket.createWebSocket();

// Scoket - TCP
import socket from '@ohos.net.socket';
// udp 类似:constructUDPSocketInstance()
let tcp = socket.constructTCPSocketInstance();

在示例创建之后,通过on()方法的入参类型指定请求的行为订阅:

javascript 复制代码
// ws 为 WebSocket 实例,tcp 为 Socket 实例
ws.on('open', (err, value) => {
    console.log("on open, status:" + JSON.stringify(value));
    // 当收到on('open')事件时,可以通过send()方法与服务器进行通信
    ws.send("Hello, server!", (err, value) => {
    });
});
ws.on('message', (err, value) => {
    console.log("on message, message:" + value);
    // 当收到服务器的`bye`消息时(此消息字段仅为示意,具体字段需要与服务器协商),主动断开连接
    if (value === 'bye') {
        ws.close((err, value) => {
        });
    }
});
ws.on('close', (err, value) => {
    console.log("on close, code is " + value.code + ", reason is " + value.reason);
});

// 订阅TCPSocket相关的订阅事件
tcp.on('message', value => {
  console.log("on message")
  let buffer = value.message
  let dataView = new DataView(buffer)
  let str = ""
  for (let i = 0; i < dataView.byteLength; ++i) {
    str += String.fromCharCode(dataView.getUint8(i))
  }
  console.log("on connect received:" + str)
});

tcp.on('connect', () => {
  console.log("on connect")
});

tcp.on('close', () => {
  console.log("on close")
});

注意,虽说两者 API 上比较类似,但是订阅时传入的 type 类似还是有所区别,要仔细区分。

其次,Socket 在实现上会比 WebSocket 更加复杂一点,本身基于 Https 的 WebSocket 是具备安全能力的,但是 Socket 还是需要去实现 TLS 去实现网络通信过程中的安全保障。

总结

该篇通过代码示例的方式介绍了 HarmonyOS 常用的应用功能模块的实现,结合一些细小的注意点给开发者提供一些参考。

作者:ES2049 / 拂晓

文章可随意转载,但请保留此 原文链接

非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com

相关推荐
前端小小王19 分钟前
React Hooks
前端·javascript·react.js
迷途小码农零零发29 分钟前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪1 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6413 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js
dz88i84 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr4 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook