记鸿蒙OpenHarmony应用开发初体验的一篇文章

1,357 阅读19分钟

一、 HarmonyOS介绍

导读:本文为记录一次鸿蒙初体验开发,以及基本入门教程的文章,在首章主要讲鸿蒙系统的优势和更正我们开发同学对鸿蒙系统一些常见的认识误区;第二章主要简单了解了一下ArkTS语法核心内容,有语法基础的简单看下即可;第三章是ArkUI的一些基本控件的使用方式,第四章为鸿蒙组件化、自定义组件、组件间通信方式和鸿蒙现阶段还存在的缺陷。后续会持续更新...

本文涉及的代码内容:github.com/FrankSem-op…

HarmonyOS是什么

HarmonyOS,中文称鸿蒙系统或鸿蒙OS。虽然已经广为流传,但很多人对鸿蒙系统还存在误解,以为鸿蒙其实类似于Android、iOS只是给手机、平板用的移动操作系统。其实这个说法不全面,鸿蒙系统是华为推出的面向全场景的分布式操作系统。所谓面向全场景是指它将来不光是给予手机、平板搭载,而是会对诸如电脑、车机、手表、电视、音响、眼镜及各种家电家居等等多设备都能搭载的操作系统。这就是华为所宣导的全场景智慧生活战略1+8+N(手机+8种常用设备+万物,共同协作共同参与)

image.png

  • 因此,国内厂商为了覆盖这么多终端,必然需要对应的应用,所以鸿蒙系统将带来大量岗位。待到9.30左右HarmonyOS Next发布正式版面向普通用户,绝对是爆发之时。

  • 对于普通用户而言,其实不需要关注技术上有哪些特点。就好像大部分人是说不出iOS和安卓技术上有哪些区别,但能感觉出某些方面哪个系统更好用,这就是所谓的“用户体验”。但对于我们移动开发同学而言,是有必要泛泛的从技术方向了解下鸿蒙系统的特点。

  • 总体说来,HarmonyOS的特点,其实华为提炼出了三大特征:

    一次开发,多端部署

    可分可合,自由流转

    统一生态,原生智能

一次开发,多端部署
  • 简单来讲就是一个工程、一次开发上架,即可用于所有设备(开发者能根据功能按需部署)
  • 对于移动端或者大前端同学都知道,一套代码完美运行于多个设备有多么难;光界面布局要想在各种设备上完美呈现就极其麻烦,很多同学一听响应式布局就头皮发麻。更何况还要功能适配、硬件适配……

言归正传:华为又是怎么让实现一次开发,多端部署的呢?

  • 简而言之就是华为提供了一堆核心能力,把一些功能高度抽象出统一的接口让程序员调用,调用后即可在不同设备呈现不同效果(类似于面向对象里的多态)。当然这句话有点抽象,再讲直白一点,就好比是华为给你提供了一把瑞士军刀,对我们而言,我们只是要调用这把军刀。但在不同的环境下,这把凳子自己有不同的功能。比如你在一个人在野外生存的时候它可以用来防野兽,保证人身安全,在你料理的时候又可以当菜刀来用,区别就是你打开了哪个刀口而已。
可分可合,自由流转
  • 意思就是:HarmonyOS支持在不同设备之间的无缝切换和协作。用户可以在一个设备上开始任务,然后很方便地转移到另一个设备上继续操作,非常类似于iphone的appleId,通过绑定设备间的关系,来保存用户的操作状态。

  • 这种特性增强了设备间的协同工作能力,提供了更流畅的用户体验。例如,用户可以在手机上查看内容,然后在电视上继续观看,或者在平板上编辑文档后在电脑上进行进一步处理。

(这就是任老爷子强调的“生态”)

image.png

统一生态,原生智能
  • HarmonyOS构建了一个统一的生态系统,使得所有兼容设备能够协同工作,互联互通。同时,它也集成了原生的智能技术,比如AI能力,用于提升设备的智能水平。

  • 统一生态使得用户可以在各种设备上享受一致的体验,而原生智能技术能够提供更智能的服务和功能,如智能推荐、语音识别等,增强了设备的智能化和个性化。

所以个人认为认为鸿蒙可能会蚕食安卓的其中一部分原因就是因为鸿蒙的这三大特性。这三大特性不是安卓实现不了,而是安卓能实现,但不一定在设备上有统一标准,毕竟安卓现在太碎片化,各厂商有自己的深度定制,很难形成统一标准。更何况鸿蒙依托国内环境,在国家号召核心技术自主化的大背景下,更具有地利。

介绍完HarmonyOS,我们正式进入开发学习阶段。

二、认识ArkTS与TypeScrip语言

按照官网上的说法,ArkTS是HarmonyOS的主力应用开发语言。

它在TypeScript(简称TS)的基础上,匹配ArkUI框架,扩展了声明式UI、状态管理等相应的能力,让开发者以更简洁、更自然的方式开发跨端应用。

”工欲善其事,必先利其器”,我们先来简单了解一下ArkTS与TypeScrip:

补充:ArkTS、TypeScript和JavaScript之间的关系: JavaScript是一种属于网络的高级脚本语言,已经被广泛用于Web应用开发,常用来为网页添加各式各样的动态功能,为用户提供更流畅美观的浏览效果。 TypeScript 是 JavaScript 的一个超集,它扩展了 JavaScript 的语法,通过在JavaScript的基础上添加静态类型定义构建而成,是一个开源的编程语言。 ArkTS兼容TypeScript语言,拓展了声明式UI、状态管理、并发任务等能力。

TypeScript的语法非常简单,有过Java、Kotlin、Dart等语言开发经验的小伙伴,会非常容易上手。但也有些特殊之处,我个人认为值得拿出来讲一讲:

1. 类型注解

类型注解是 TypeScript 的核心特性之一,它允许在变量、函数参数和函数返回值上添加类型信息。这有助于在编译时发现和修复类型错误。

示例:

let message: string = "Hello, HarmonyOS";
let count: number = 10;

function greet(name: string): string {
  return `Hello, ${name}`;
}

let greeting: string = greet("HarmonyOS");

2. 接口

接口是 TypeScript 中定义复杂类型的一种方式,接口可以用于类型检查,确保对象符合预期的结构。我认为值得一讲的原因是:它可以描述一个对象的结构。(这个比较重要,在后续的开发中经常用到)

示例 :

interface An9TechnicalDepartment{
    name:string
    age:numbser
    id:number
}

let sem : An9TechnicalDepartment = {
    name:'sem',
    age:18
    id:123580
}

可以看到,我们声明了一个对象,而这个对象可以继承于我们定义的接口,相比我们kotlin中的data类,个人认为是更易于我们去理解和阅读的。

3. 箭头函数

与其他编程语言一样,ArkTS支持函数定义与调用,比较不同的是:ArkTS支持箭头函数,这是一种比普通函数更加简洁的函数写法,最大的优点就在于它极简

let logSomething = () => {
    console.log("遥遥领先")
}

logSomething()

4. 对象方法

即属于对象的方法,描述对象的具体行为,可以与我们上述的箭头函数一起配合使用

示例:

interface BlackWuKong {
  attack: () => void,
  records: (action: string) => void
}

let Malou :BlackWuKong = {
  attack: (): void => {
    console.log("敲")
  },
  records: (action: string): void => {
    console.log("存档","上香",action)
  }
}

5. 对象数组

// 定义一个 Person 类
class Person {
    name: string;
    age: number;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }

    getDetails(): string {
        return `${this.name} is ${this.age} years old.`;
    }
}

// 创建 Person 对象数组
const people: Person[] = [
    new Person("Alice", 30),
    new Person("Bob", 25),
    new Person("Charlie", 35)
];

// 遍历对象数组并输出每个对象的详情
people.forEach(person => {
    console.log(person.getDetails());
});

6. 联合类型

联合类型是一种灵活的数据类型,它修饰的变量可以存储不同类型的数据,这个相比于大部分的编程语言算是一种小创新了,解决我们很多时候数据传递时候泛型不统一的痛点。

语法:let 变量 : 类型1 | 类型2 | 类型3 = 值

示例 ⓵ :

let trackId :number | string = 12508

trackId = "12580" //即使初始化是number类型,也可以在后续修改成string类型

示例 ⓶ :

let gender :'male' | 'female' | 'transgender' =  'transgender'

gender = '0' //此行会报错,因为已经声明了只有三种类型,不能是其他类型

7. 模块

ArkTS 支持模块化编程,你可以把代码分割成多个模块,每个模块有自己的作用域,并通过导出(export)和导入(import)进行模块之间的交互。

示例:

// math.ts
export function add(x: number, y: number): number {
  return x + y;
}

export function subtract(x: number, y: number): number {
  return x - y;
}

// app.ts
import { add, subtract } from './math';

console.log(add(10, 5)); // Output: 15
console.log(subtract(10, 5)); // Output: 5

8. 异步编程

ArkTS 支持 Promise 和 async/await 语法,使得异步编程变得更加简洁明了。

示例:

async function fetchData(url: string): Promise<Data> {
  let response = await fetchData(url);
  let data = await response.json();
  return data;
}

fetchData('https://api.example.com/data')
  .then(data => console.log(data))
  .catch(error => console.error(error));

async function fetchData(url: string): Promise<Data>定义了一个名为fetchData的异步函数,它接收一个字符串类型的参数url,并返回一个 Promise,这个 Promise 最终会解析为类型为Data的值(这里假设Data是某种数据类型,可能是一个对象或者数组等,具体取决于实际的应用场景)。

let response = await fetch(url);使用fetch函数发起对给定url的网络请求。await关键字使得函数在这个异步操作完成之前暂停执行,一旦请求完成,response变量将包含服务器的响应。

let data = await response.json();在接收到响应后,调用response.json()方法将响应体解析为 JSON 对象。同样,await确保在解析完成之前函数不会继续执行。最后,函数返回解析后的 JSON 数据。

fetchData('https://api.example.com/data')调用了上面定义的fetchData函数,并传入一个具体的 URL。这会触发异步的网络请求操作。

.then(data => console.log(data))如果网络请求成功并且数据解析成功,这个回调函数会被调用,并将解析后的数据作为参数传入,这里只是简单地将数据打印到控制台。

.catch(error => console.error(error))如果在网络请求或者数据解析过程中出现任何错误,这个回调函数会被调用,并将错误对象作为参数传入,这里将错误信息打印到控制台以便进行调试。

9. 类型别名

类型别名允许你为现有类型创建一个新的名字。这对于创建复杂类型或提高代码可读性非常有用。

示例:

type Point = {
  x: number;
  y: number;
};

function drawPoint(point: Point): void {
  console.log(`Drawing point at (${point.x}, ${point.y})`);
}

let point: Point = { x: 10, y: 20 };
drawPoint(point);

10. 类型保护

类型保护是一种检查变量类型的方法,可以在编译时确保变量具有正确的类型。这对于处理联合类型或处理类型转换时非常有用。

示例:

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  if (shape instanceof Circle) {
    return Math.PI * shape.radius ** 2;
  } else {
    return shape.width * shape.height;
  }
}

11. 映射类型

映射类型允许你根据现有类型创建新的类型,例如将一个对象的所有属性设置为只读或可选。这在处理现有类型时非常有用,可以避免创建重复的类型定义。

type ReadonlyPoint = Readonly<Point>;

let readonlyPoint: ReadonlyPoint = { x: 10, y: 20 };
readonlyPoint.x = 30;  // Error: Cannot assign to 'x' because it is a read-only property
//(即会报出x是只读类型的变量)

对开发语法有了简单的认识之后,我们再来看一下如何去筋洗简单的页面开发。

三、 简单实践与认识ArkUI布局基础

1、熟悉一下工程目录

进行开发之前我们先来熟悉一下工程目录:

├── .hvigor                                         # 存放编译构建相关的临时文件或配置  
├── .idea                                           # IDE(如IntelliJ IDEA)的配置目录  
├── AppScope  
│   └── entry                                       # HarmonyOS工程模块,编译构建生成一个HAP包  
│       ├── src  
│       │   ├── main  
│       │   │   └── ets  
│       │   │       ├── entryability                # 应用/服务的入口  
│       │   │       │   └── [ArkTS源码文件]  
│       │   │       └── entrybackupability          # 应用提供扩展的备份恢复能力  
│       │   │           └── [ArkTS源码文件]  
│       │   ├── pages                               # 应用/服务包含的页面  
│       │   │   └── Index.ets                       # 页面源码文件  
│       │   └── resources                           # 存放应用/服务所用到的资源文件  
│       │       └── mock  
│       │           └── ohosTest  
│       │               ├── test  
│       │               │   └── .gitignore  
│       │               ├── build-profile.json5     # 模块信息、编译信息配置项  
│       │               ├── hvigorfile.ts           # 模块级编译构建任务脚本  
│       │               ├── obfuscation-rules.txt   # 混淆规则文件  
│       │               └── oh-package.json5        # 描述包名、版本、入口文件和依赖项等信息  
│       └── module.json5                            # 模块配置文件  
├── .gitignore                                      # Git忽略文件配置  
├── build-profile.json5                             # 工程级配置信息  
├── hvigorfile.ts                                   # 工程级编译构建任务脚本  
├── obfuscation-rules.txt                           # 混淆规则文件  
├── oh-package.json5                                # 描述全局配置,如依赖覆盖、依赖关系重写和参数化配置  
└── oh_modules                                      # 存放三方库依赖信息  
    ├── .gitignore  
    ├── build-profile.json5                         # 三方库编译信息配置  
    ├── hvigorfile.ts                               # 三方库编译构建任务脚本  
    ├── local.properties                            # 本地属性配置(如SDK路径)  
    └── oh-package-lock.json5                       # 锁定依赖版本,确保项目依赖的一致性

对于我们初学者,它的目录可以简化如下

├── AppScope  
│   └── entry                            # HarmonyOS工程模块,编译构建生成一个HAP包  
│       ├── src  
│       │   ├── main                     # 应用/服务的入口 
│       │   ├── pages                    # 应用/服务包含的页面(写页面逻辑的地方)  
│       │   │   └── Index.ets            # 页面源码文件  
│       │   └── resources                # 存放应用/服务所用到的资源文件  
│       └── module.json5                 # 模块配置文件  

2、剖析ArtTs的UI范式语法

在正式开发前,我们先简单学习一下默认模板的代码,熟悉一下ArtTS的UI范式语法

@Entry
@Component
struct HelLo {
  @State myText:string ='wortd'
  build(){
    Column(){
      Text('Hello')
        .fontsize(50)
      Text(this.myText)
        .fontsize(50)
      Divider()
      Button(){
        Text('CLick me')
          .fontsize(30)
          .onClick(()=>
          this.myText='ArkUI'
          )
      }
      width(200)
        .height(50)
    }
  }
}

我们先来简单剖析一下这段代码:

image.png

  • 装饰器: 对后端来说装饰器在简单不过了,对于前端来说,我们可以理解为它用于装饰类、结构、方法以及变量,并赋予他们特殊的含义。如上述示例中@Entry、@Component和@State都是装饰器,@Component表示自定义组件,@Entry表示该自定义组件为入口组件,@State表示组件中的状态变量,状态变量变化会触发UI刷新。

  • UI描述:以声明式的方式来描述UI的结构,例如build()方法中的代码块。

  • 自定义组件:可复用的UI单元,可组合其他组件,如上述被@Component装饰的struct Index。

  • 系统组件:ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的Column、Text、Divider、Button。

  • 属性方法:组件可以通过链式调用配置多项属性,如fontSize()、width()、height()、backgroundColor()等。

  • 系统组件、属性方法、事件方法具体使用可参考基于ArkTS的声明式开发范式

导读:关于装饰器和组件等知识我们会在后面做更详细的说明和验证,这里只是先让大家简单的了解和熟悉我们的开发页面和UI范式语法

3、认识常用组件容器

同android开发一样,华为鸿蒙官方也提供了很多基础组件,下面介绍几种容器组件和常用基础组件的使用方式。

⓵ 容器组件

鸿蒙组件描述
Column沿垂直方向布局的容器
Row沿水平方向布局容器
RelativeContainer相对约束布局容器组件
Flex以弹性方式布局子组件的容器组件
Grid网格容器组件
GridRow栅格容器组件
List沿水平方向布局容器组件
Tabs标签页容器组件

Column&Row 排列容器组件

Column竖向排列容器组件,等同于竖向的LinerLayout。Row横向排列的容器组件,等同于横向的LinerLayou。使用方法如下:

Column() {
  Text("1")
  ...
  Text("2")
  ...
  Text("")
  ...
}
Row() {
  Text("1")
  ...
  Text("2")
  ...
  Text("3")
  ...
}

关键属性:

属性名称作用描述
justifyContent设置子组件在主轴方向上的对齐格式Colmun-FlexAlign属性参考Row-VerticalAlign属性参考
alignItems设置子组件在交叉轴方向上的对齐格Column-HorizontalAlign属性参考Row-VerticAlalig属性参考
RelativeContainer 相对布局容器组件

RleativeContainer相当于android中的RelativeLayout,使用方式如下:

@Entry
@Component
struct RelativeContainerUi {
  build() {
    RelativeContainer() {
      Text("遥遥领先")
        .fontSize(15)
        .fontColor(Color.White)
        .alignRules({
          top: { anchor: "__container__", align: VerticalAlign.Top },
          left: { anchor: "__container__", align: HorizontalAlign.Start }
        })
        .id("one_txt")
        .width(150)
        .height(50)
        .textAlign(TextAlign.Center)
        .borderRadius('50%')
        .padding(10)
        .backgroundColor('rgba(255, 255, 0, 0.2)')

      Text("娜娜在右边")
        .fontSize(15)
        .fontColor(Color.White)
        .alignRules({
          top: { anchor: "one_txt", align: VerticalAlign.Top },
          left: { anchor: "one_txt", align: HorizontalAlign.End }
        })
        .id("two_txt")
        .width(150)
        .padding(10)
        .height(50)
        .margin({ left: 20 ,top:20})
        .borderRadius('50%')
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(0, 255, 255, 0.2)')
      Text("琪琪落后")
        .fontSize(15)
        .fontColor(Color.White)
        .alignRules({
          top: { anchor: "two_txt", align: VerticalAlign.Bottom },
          left: { anchor: "one_txt", align: HorizontalAlign.End }
        })
        .id("three_txt")
        .width(150)
        .margin({ top: 60 })
        .height(50)
        .padding(10)
        .borderRadius('50%')
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(0, 255, 0, 0.2)')

    }.width("100%").height("100%")
    .padding({ left:30, top:70 })
    .backgroundColor('#4682b4')
  }
}

首先需要给子组件设置"id",然后通过"alignRules"定义规则,“anchor”锚点。 效果如下:

image.png

List 列表组件

List列表展示组件,同Listview,List组件包含两种子组件ListItemListItemGroup,下面是List的简单使用:

@Component
export struct ListUi {
  @State arr: string[] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"]

  build() {
    Column() {
      List({ space: 20, initialIndex: 0 }) {
        ForEach(this.arr, (item) => {
          ListItem() {
            Text('' + item)
              .width('100%')
              .height(100)
              .fontSize(16)
              .textAlign(TextAlign.Center)
              .borderRadius(10)
              .backgroundColor(0xFFFFFF)
          }
          .border({ width: 2, color: Color.Green })
        }, item => item)
      }
      .height("100%")
      .width("100%")
      .padding(20)
    }
  }
}

效果如下:

2024-09-06 10.32.25.gif

关键属性和接口:

属性或者接口名称描述
ListDirection(..)排列方向(竖向Axis.Vertical、横向Axis.Horizontal)
space子控件主方向上的间隔
initialIndex设置当前List初次加载时视口起始位置显示的item的索引值
scroller可滚动组件的控制器。用于与可滚动组件进行绑定
divider设置ListItem分割线样式,默认无分割线
cachedCount设置列表中ListItem/ListItemGroup的预加载数量,其中ListItemGroup将作为一个整体进行计算,ListItemGroup中的所有ListItem会一次性全部加载出来
lanes9+api 9新增,以列模式为例(listDirection为Axis.Vertical),lanes用于决定List组件在交叉轴方向按几列布局。
onScroll(event: (scrollOffset: number, scrollState: ScrollState) => void)滚动事件监听
其它参考官方文档
Grid 网格组件

Grid网格布局组件,同GridView,简单使用方式如下:

export struct GridUi {
  @State arr: string[] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"]

  build() {
    Grid() {
      ForEach(this.arr, (day: string) => {
        GridItem() {
          Text("item" + day)
            .fontSize(16)
            .backgroundColor('#4682b4')
            .width('22%')
            .fontColor(Color.White)
            .height(100)
            .textAlign(TextAlign.Center)
        }.padding(4)
      }, (day: string) => day)
    }

    .width('100%')
    .backgroundColor(Color.White)
    .height('100%')
  }
}

image.png

关键属性和接口:

属性或接口名称描述
columnsTemplate设置当前网格布局列的数量,不设置时默认1列。例如, '1fr 1fr 2fr' 是将父组件分3列,将父组件允许的宽分为4等份,第一列占1份,第二列占1份,第三列占2份。
rowsTemplate设置当前网格布局行的数量,不设置时默认1行。例如,'1fr 1fr 2fr'是将父组件分三行,将父组件允许的高分为4等份,第一行占1份,第二行占一份,第三行占2份。
columnsGap设置列与列的间距
rowsGap设置行与行的间距
GridDirection沿主轴布局方向

我们可以发现List和Grid都不再需要通过Adapter来实现数据的绑定了,而是通过forEeach渲染来实现多item的渲染,我们来简单了解一下forEach渲染机制。

理解ForEach渲染控制:

⓵ 基本概念 :

在鸿蒙开发中,ForEach是一种用于遍历数组或可迭代对象并动态渲染组件的机制,若需要根据数组数据生成一堆同类型的组件,则需要用到ForEach,根据数组生成组件,数组有多少个元素,就生成多少个组件

⓶ 结构语法:

ForEach(arrayOrIterable, (element, index) => { 
    // 返回一个组件实例 return ComponentForEach(element, index); 
})
  • arrayOrIterable:要遍历的数组或可迭代对象。
  • element:当前遍历到的元素。
  • index:当前元素的索引。

⓷ 工作原理

  • 数据绑定: ForEach会自动监测绑定的数组或可迭代对象的变化。如果数据源发生改变(例如添加、删除或修改元素),ForEach会相应地更新界面上的组件。

  • 组件生成:对于数据源中的每个元素,都会调用提供的函数,并传入当前元素和索引。这个函数应该返回一个组件实例,该组件将被渲染在界面上。

  • 性能优化:鸿蒙的渲染系统会尽量高效地处理ForEach的渲染。它会尝试最小化不必要的重新渲染,只更新那些由于数据源变化而受影响的组件。

⓸ 使用场景

  • 列表渲染:当需要展示一个列表时,比如展示一组商品、联系人或消息,可以使用ForEach来遍历数据源并为每个元素创建一个列表项组件。

  • 动态界面生成:如果界面的一部分需要根据动态数据来生成不同数量的组件,可以使用ForEach根据数据的变化实时更新界面。

⓹ 注意事项

  • 数据源稳定性:确保数据源在渲染过程中不会被意外修改,否则可能会导致不可预测的渲染结果。如果需要修改数据源,最好在合适的时机进行,并确保界面能够正确地响应这些变化。

  • 性能考虑:虽然ForEach会进行一定的性能优化,但如果数据源非常大,可能会对性能产生影响。在这种情况下,可以考虑分页加载数据或使用其他优化技术。

  • 索引的使用:索引参数可以在某些情况下很有用,比如为每个组件设置唯一的key属性,以帮助渲染系统更高效地进行更新。

Stack - 层叠容器组件

在App效果中,我们经常看到一些阴影蒙版加载中遮罩悬浮小窗口等,如下图列表所示:

image.png

像这种:把某个组件堆叠到另外一个组件上的效果,称之为层叠效果或者堆叠效果(就像小时候玩的叠罗汉,一个人叠在另一个人上面)

image.png

使用语法

Stack() {
  item1()
  item2()
  item3()
}

默认情况下:各个item居中对齐,越在后面的组件越叠在最高层,如图所示:

image.png

也可以通过修改Stack的alignContent属性,得到下面的效果:

image.png

修改方法

Stack({ alignContent: Alignment.TopStart }) { 
    // 改成在左上对齐堆叠 ..... 
}

如果需要手动指定谁在最上层,可以使用zIndex属性,修改层级。zIndex的值越大越叠在最上面

示例:

@Entry
@Component
export struct StackUi {
  build() {
    Stack() {
      Text('item1')
        .fontSize(12)
        .fontColor('#fff')
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(0, 0, 255, 0.2)')
        .textAlign(TextAlign.Start)
        .align(Alignment.Top)
        .zIndex(3)
      Text('item2')
        .fontSize(12)
        .fontColor('#fff')
        .fontWeight(FontWeight.Bold)
        .width('60%')
        .height('60%')
        .backgroundColor('rgba(0, 255, 0, 0.2)') 
        .textAlign(TextAlign.Start)
        .align(Alignment.Top)
        .zIndex(1)
      Text('item3')
        .fontSize(12)
        .fontColor('#fff')
        .fontWeight(FontWeight.Bold)
        .width('30%')
        .height('30%')
        .backgroundColor('rgba(255, 255, 0, 0.2)')
        .textAlign(TextAlign.Start)
        .align(Alignment.Top)
        .zIndex(2)
    }
    .alignContent(Alignment.BottomEnd)
    .width("100%")
    .height("40%")
    .backgroundColor('rgba(8, 8, 8, 0.2)')
  }
}

效果:

image.png

可以看到item2和item3的字体颜色看起来是有一些被盖着的,因为item1是处于最上层

⓶ 常用基础组件

android组件鸿蒙组件描述
TextViewText用于文本显示
EditTextTextInput用于文本文本输入
ButtonButton用于点击按钮
ImageViewImage图片显示
Text 文本显示控件

这个控件是个开发都能知道是显示文本的控件,不必多说

Text('开始学习HarmonyOS NEXT')
  .fontSize(22)
  .fontColor('#fff')
  .fontWeight(FontWeight.Bold)

效果如下:

image.png

关键属性:

名称描述
textAlign设置文本段落在水平方向的对齐方式默认值:TextAlign.Start
textOverflow设置文本超长时的显示方式。默认值:{overflow: TextOverflow.Clip}
maxLines设置文本的最大行数
lineHeight设置文本的文本行高,设置值不大于0时,不限制文本行高,自适应字体大小,Length为number类型时单位为fp
decoration设置文本装饰线样式及其颜色
letterSpacing设置文本字符间距
minFontSize设置文本最小显示字号
maxFontSize设置文本最大显示字号
TextInput 文本输入控件
TextInput({ placeholder: '请输入密码' })
  .width(400)
  .height(40)
  .margin(20)
  .type(InputType.Password)
  .maxLength(9)
  .showPasswordIcon(true)

效果如下:

image.png

关键属性:

名称描述
type设置输入框类型(密码、数字、邮箱等)
placeholderColor设置placeholder文本颜色
placeholderFont设置placeholder文本样式
enterKeyType设置输入法回车键类型,目前仅支持默认类型显示
caretColor设置输入框光标颜色
maxLength设置文本的最大输入字符数
inputFilter8+正则表达式,匹配表达式的输入允许显示,不匹配的输入将被过滤。目前仅支持单个字符匹配,不支持字符串匹配
textAlign9+设置输入文本在输入框中的对齐方式
onChange(callback: (value: string) => void)输入内容发生变化时,触发该回调
onSubmit(callback: (enterKey: EnterKeyType) => void)按下输入法回车键触发该回调,返回值为当前输入法回车键的类型
onEditChange(callback: (isEditing: boolean) => void)8+输入状态变化时,触发该回调
Image 图片加载控件

Image同ImageView,用于图片展示,支持png、jpg、bmp、svg和gif类型的图片格式。下面加载图片和网络图片案例:

export struct ImageUi {
  build() {
    Column() {
      Image($r('app.media.harmony_test')).width('30%').width('100%')
      Text("本地图片").width("100%").textAlign(TextAlign.Center).fontSize(22).fontColor(Color.Black).margin({top:20})

      Image("https://img1.baidu.com/it/u=4091031159,3987317755&fm=253&fmt=auto&app=120&f=JPEG?w=888&h=500").width('30%').width('100%').margin({top:40})
      Text("网络图片").width("100%").textAlign(TextAlign.Center).fontSize(22).fontColor(Color.Black).margin({top:20})
    }.width('100%')
    .height('100%')
  }
}
  • 注意网络图片需要在module.json里面配置网络权限:ohos.permission.INTERNET

image.png

四、 通过实战认识自定义组件、组件化开发思想与状态更新

这一章节我们来进行一个简单实战,实现类似于MAC OS自带的备忘录一样的小demo,通过这个小项目来认识我们开发过程中遇到与harmony开发相关的一些知识点,后续也会不断完善这个demo,前面所涉及到的内容也在该仓库下。

github:github.com/FrankSem-op…

  • 主要效果如下:

2024-09-05 14.09.26.gif

1、自定义组件

为什么需要?

  • 很多时候我们需要自定义一些由其他组件布局组合在一起的组件,方便在界面上复用,例如我们这个待办事项的每个小item,组成元素比较多,比如有Row、Checkbox、Text等。而且这些元素组合起来的部分,多次需要使用。那么为了更好的维护、修改、复用,可以把这一部分封装成自定义组件

image.png

组件怎么创建?

  • 一般情况下,为了更好的管理项目中的文件,我们会把自定义组件放到一个跟pages目录同级的新的文件夹里,起名叫components或者view。文件夹起名每个人都有自己的喜好,无需强求。但是本文后面以view起名作为存放组件的文件夹

    华为鸿蒙官网的Codelabs上大部分示例代码都是以view作为文件夹,所以这里也保持同步

image.png

如何快速生成一个组件?

敲“comp”,然后回车即可,DevEco会自动帮我们生成模板:

2024-09-06 13.56.47.gif

@Component说明
  • 这是一个装饰器

  • 通过上一篇的学习我们了解到装饰器可以让某个数据具备特殊功能,例如@State可以让数据驱动UI更新

  • 所以@Component这个装饰器就是能让struct这个数据具备组件的功能

  • 因此你会发现默认生成的Index.ets和我们自定义的组件ToDoItem都有这个装饰器

@Entry又是什么呢?
  • 入口的意思
  • 作用:把某个组件作为这个页面的第一个入口组件启动
  • 一个页面有很多自定义组件,那么启动这个页面到底以哪个组件作为入口组件呢?就是通过@Entry来指定的
  • 并且,加了@Entry的组件,也能被预览器预览
预览自定义组件
  • 自定义组件创建完,我们需要一边写代码一边看效果。可是默认情况下自定义组件无法在预览器里进行预览,这时候需要加一个装饰器@Preview

构建TodoItem自定义组件的界面

image.png

  • 根据效果图分析发现这个组件根容器应该是一个Row,里面两个子组件:CheckboxText
  • 且Row需要设置圆角、背景色、最好给个高度,Checkbox需要给左右外间距
  • 代码如下
  build() {
    Row() {
      Checkbox()
        .margin({ left: 20, right: 20 })
      Text('学习harmony组件话相关内容')
    }
    .width('100%')
    .height(40)
    .backgroundColor(Color.White)
    .borderRadius(20)
  }
如何使用自定义组件
  • 需要先导出、再导入,即可使用

  • 导出:只要在struct前加一个export即可

export struct ToDoItem {
  ....
}
  • 导入:import语法
import { 组件名 } from '路径'
// 例
import { ToDoItem } from '../view/ToDoItem'

我们也可以不写导入的代码,让DevEco自动生成

  • 做法:给组件加完export后,来到需要用到组件的地方,直接写组件名,出提示后按回车

image.png

image.png

  • 至此,我们完成了TodoItem组件的简单编写。并通过它学习了装饰器、组件创建和使用的相关知识。我们再来分析一下改如何实现我们的页面

image.png

很显然,有了组件化创建的思维,我们不应再一个文件里面去实现一整个复杂的布局,而是要把复杂的页面进行拆分和细化,像我们的备忘录就应该拆分成顶部显示区输入任务区列表区。把不同的区别单独拆分出一个小组件,以便我们去复用、调整布局,在后续维护上也会方便很多。

2、状态更新

我们实现一个页面,肯定会存在许多界面与用户交互的逻辑,用户执行操作之后,组件的状态跟随着变化,而ArkUI的状态更新是基于双向绑定实现的: 即数据一旦改变,界面跟着变。 界面输入内容有变化,数据也跟着变。

装饰器 - @State

我们先来复习一下最常用、最基础的装饰器:

在我们实现实现双向绑定的时候,只需要声明一个变量,并在需要使用的地方通过 ‘${变量}’的方式去调用、即可实现双向绑定,但当数据在后续触发更新的时候,要使得页面也随之更新,就需要用到我们的 @State 装饰器。

默认声明的成员变量不具备数据改变触发界面更新渲染的功能,需要使用装饰器来实现触发数据更新的时候重新渲染页面,实现组件状态跟随更新

@State 用法:

@State 变量: 类型 = '初始值'

组件传参 - 父传子

当我们把列表拆分成不同的组件的时候,就会存在这样的关系:ToDoMain(整个备忘录控件)相对于ToDoHeader(头部控件)、ToDoInput(输入控件)、ToDoList(任务列表)是父与子的关系,这的时候要使得在父控件更新数据时,子控件同步更新就需要用到另一个修饰器 @Prop

我们来简单了解一下@Prop:

@Prop的作用:

  • 数据传递:允许父组件向子组件传递数据。

  • 组件属性:子组件通过 @Prop 装饰器声明接收的属性,这些属性可以是基础数据类型或复杂对象。

  • 双向绑定:虽然 @Prop 主要用于单向数据流,实际使用中我们可以结合其他方式实现双向绑定。

让我们来检验一下这个被别人验证的真理:

例如,本案例中我们有 TodoMainTodoItem,因为TodoMain包含了TodoItem。所以TodoMain是父TodoItem是子。我们就用这两个组件试试父传子

  • 代码步骤:

  • TodoItem里声明一个变量叫name,并在Text里使用,代码如下

export struct TodoItem {
  // 声明个成员变量,等待父传,注意:此时没加任意装饰器
  name: string = ''

  build() {
		.......
    Text(this.name)
  }
}

TodoMain里声明一个变量name,并传递给TodoItem

@Component
export struct TodoMain {
  .......
  @State name: string = 'abc'


  build() {
    Column({ space: 10 }) {
      ........
      
      Row() { ...... } 
      .onClick(() => { this.name = '修改成功' })
      
      ForEach(this.todoList, (item: number) => {
        // 这里是传参,把父的name传递给了子里的name
        TodoItem({ name: this.name })
      })
    }
    .width('100%')
  }
}

此时会发现,正因为把父的name,也即数据为abc,传递给了子,所以此时TodoItem显示的即为abc,如视频所示:

2024-09-08 16.00.46.gif

可以看到点击了并没有重新渲染页面以刷新ui

我们把@Prop修饰在我们的子控件的数据对象试试:

@Prop name: string = '' //在原来的name对象上修饰

2024-09-08 16.05.55.gif

可以看到在子控件添加装饰器@Prop,可以在数据发生变化的时候重新渲染页面。

让我们来与 @State 对比学习一下:

  • @State: 主要是装饰给组件自己使用的数据,效果:能让成员变量的值改变后,界面也能刷新

  • @Prop: 主要是用在作为子组件时,用来装饰由父传递过来的数据,效果:能让父的数据改变子也能接收到

  • 注意:在ArkTS中,即使父传递的是引用类型的数据,若不加@Prop修饰,一样会导致父的数据改变子里不会变,同学们有兴趣可以自行测试

组件传参 - 父传子双向同步

  • 上面我们讲到,子里的成员变量加@Prop后,即可让父的数据改变,子随之改变,也即父的数据自动同步到子

  • 但是,目前无法实现子同步到父,也即子里改变了这个父传进来的数据,子里自身能改变,但是父的无法改变。也即Vue框架里的单向数据流

  • 例:在TodoMain里用一个Text显示name的值,并在TodoItem里给Row加点击事件并修改name的值,我们可观察效果

export struct TodoItem {
  // 此时加了@Prop修饰
  @Prop name: string = ''

  build() {
    Row() {
     ......
    }
    .onClick(() => {
      this.name = '点击子控件,更新ui'
    })
   
   .......
  }
}

并在父布局声明一个Text,同样显示name的值:


@State name: string = 'abc'

build() {
 //省略其他代码...
    Column({ space: 10 }) {
 //省略其他代码...
        Text("当前父控件内容:"+this.name)
          .margin({top:20,bottom:20})
}
 //省略其他代码...
}

让我们来看看效果:

2024-09-08 16.36.43.gif

发现当我们点击`TodoItem`时,`TodoItem`自身的name数据了`点击子控件,更新ui`,但是父控件里的还是第一次修改的结果`修改成功`

如果我们把子控件ToDoItem中的name换成用 @Link 修饰,(被@Link修饰的变量不可以初始化,所以直接替换该修饰器会报错,我们需要去掉初始值)那么就会是这样的效果:

2024-09-08 16.55.13.gif

可以看到:当我们点击子控件后,父控件内容中的name变量也会跟着改变,并且页面重新渲染,刷新了父控件,每一个子item的内容也跟着改变,这是因为在父控件ToDoMain中,每个子item的Text也是用了name这个成员变量,这就说明 @Link 是支持### 父传子双向同步的。

总结@Prop@Link 相同的和不同点:

  • 相同点:

    ⓵ 都是用在子组件,用来接收父传递过来的数据,

    ⓶ 都可以实现父改变数据后同步给子

  • 不同点:

    ⓵ 初始化值不同:@Prop需要初始化值,相当于给默认值。可以实现,父如果传了就用父的数据,如果没传则用默认值 @Link不能初始化,相当于必须要父传递数据了才有数据

    ⓶ 同步给父不同:@Prop修饰的数据,子里改变了不会同步给父,@Link修饰的数据,子里改变了会同步给父

以上便是关于组件化思想、自定义组件、状态更新、组件间数据传递的相关内容,让我们来继续完善这个demo:

我们先仔细回顾一下我们备忘录的交互逻辑:

2024-09-08 17.37.55.gif

当我们点击子控件之后,要更新自己的状态,也要更新父控件的状态,输入新的任务的时候还要在子列表中添加新的item,也就是需要父子双向更新状态,很多同学会说那这只需要在子控件的列表数据用 @Link 修饰就好了,事实是这样的吗,让我们来一起验证一下:

让我们把子控件的ToDoList用@Link修饰,会发现编译器竟然报错了:

image.png

  • 原因:语法限制, @Link只能接收父组件里声明的第一层成员变量

这就是目前鸿蒙开发的缺陷缺陷,我们来一下了解一下:

目前鸿蒙开发的缺陷

我们在上述实现中遇到了报错:@Link只能接收父组件里声明的第一层成员变量

什么叫第一层?

  • 就好比一个数组,数组里全是对象。对于数组而言即为第一层,数组里的每个对象称之为第二层,以此类推

  • 再好比一个嵌套对象。即对象里有个属性又是对象,那么外层的称之为第一层,里面的属性即为第二层,以此类推

  • 所以上述报错里写的item相当于就是数组里的对象,也即第二层,所以报错

  • 出现这个语法限制的根本原因是:目前的鸿蒙开发中,默认情况下无法监听到第二层的改动。而@Link又要实现双向同步,你都无法监听到改动,又如何完成双向同步呢?

  • 所以鸿蒙也给了解决方案:使用@Observed@ObjectLink来解决。但是,这里不打算讲它。因为这个解决方案其实用起来也很麻烦繁琐,非常不人性化。

那,这里怎么解决上述缺陷呢?首先,因为@Link目前不能用,那咱们就把它换回@Prop

.....
@Component
export struct TodoItem {
  @Prop item: TodoModel
  .....
}

可是@Prop又确实无法让父的数据同步改变,该怎么办呢?

9150e4e5ly1fdtlci358gj206o06odfy.jpg

既然子里无法改动到父,那就换个思路。让父,自己改!!

整体思路是: 让父提供一个修改数据的方法, 子里要修改时调用父的方法即可修改! 流程图如下:

image.png

实现: 来到`TodoMain`,声明一个方法如下
  changeStatus(item: TodoModel, index: number) {
    this.todoList[index] = {
      text: item.text,
      finished: !item.finished
    }
  }

解释:

⓵ 本方法需要传入被点的item以及被点的item的索引

② 通过索引的方式改掉数组中这一项,文字不变,但是完成状态取反即可

  • 这时候可能有老铁有疑问:

    为什么不直接 item = !item.finished 还是那个问题:目前不支持监听第二层数据改变,直接改item还是第二层。 但数组是第一层,因此你用数组[索引]的方式,就是在改第一层数据,这是能被监听到的

答: 此时需要把这个方法传递给TodoItem,因此TodoItem需要声明一个成员方法来接收

export struct TodoItem {
  ......
  onChange: () => void = () => {}
  .....
}

然后给Select组件加onChange 事件,这个事件是当Select发生勾选状态改变就会调用的事件,在里面调用传入的onChange方法

Checkbox()
    .select(this.item.finished)
    .margin({ left: 20, right: 20 })
    .onChange(() => {
          this.onChange()
})

回到TodoMain做方法传递:此时调用TodoItem除了之前要传入的item,现在还要多一个onChange

ForEach(this.todoList, (item: TodoModel, index: number) => {
    // 此时调用TodoItem
    TodoItem({
        item, onChange: () => {
        this.changeStatus(item, index)
    }
    })
})
  • 解释参数:item即为当前变动的数据,index即为当前数据对应的下标(都是changeStatus需要用到的内容)

  • 注意看:这里我没有直接把this.changeStatus 这个方法传递给onChange,而是声明了一个新的箭头函数,只不过在箭头函数里的函数体里调用了this.changeStatus,这么做的原因是this.changeStatus方法里有this.todoList这样的代码,我们都知道this是指当前环境上下文,它在TodoMain里,就代表TodoMain这个上下文,所以它修改它的todoList没毛病。但如果你是直接把this.changeStatus传递给onChange,那它相当于是在todoItem里调用,同样的this也会变成todoItem上下文,此时它是没有todoList数组的,所以这里利用箭头函数保留当前上下文的特点,在todoMain里用箭头函数再包一层,即可保证this依然指向todoMain.

我们来验证一下运行效果:

2024-09-08 17.37.55.gif

可以看到我们的思路是正确实现我们所预想的效果

到这里我们就算了实现了我们本次教程的Demo,更多细节可拉取对应demo进行查看。

五、 阶段总结

文章篇幅到这里有点长了,但内容可能连入门也算不上,仅仅算是窥探了OpenHarmony一眼,总的来讲,OpenHarmony和Fultter、Compose算是非常相像了,有更人性化的地方,但也存在一些缺陷,不过相信都是可以在后面的版本完善起来的。但具体优劣还是需要亲身体验,后续有时候会继续关注鸿蒙相关的消息和知识点,更新文章。

感谢浏览、未完待续..