阅读视图

发现新文章,点击刷新页面。

鸿蒙开发速通(一)

ArkTS

在ArkTS中,类的字段必须在声明的时候或是在构造函数中显示初始化。类似于typescriptstrictPropertyInitialization模式。

在ArkTS中必不可少的是Struct。在日常开发中会经常用到,主要处理界面。

页面和组件

在默认生成的页面代码是这样的:

@Entry                  // 1
@ComponentV2            // 2
struct Index {          // 3
  @Local message: string = 'Hello World';   // 4

  build() {
    RelativeContainer() {    // 5
      Text(this.message)     // 6
        .id('HelloWorld')    // 7
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {     // 8
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
  1. @Entry:装饰器,表示这是一个页面(入口组件)
  2. @ComponentV2:表示自定义组件,更重要的是在组件内部使用状态管理V2版本。@Component对应的就是状态管理V1。现在推荐使用的是状态管理V2,不过参考代码大部分都是V1的。本文中使用的都是状态管理的V2版本。
  3. Struct,自定义组件。自定义组件就是@ComponentStruct的组合。
  4. @Local,组件内部的状态,这里是message。在用户点击了界面上的Hello World文本之后会显示message的内容。
  5. RelativeContainer,相对布局。在alignRules里定义了布局的规则。鸿蒙使用声明式UI来实现组件开发和布局。
  6. Text,是文本组件。
  7. id('xxxxxx'),属性方法,后面出现的fontSize()alignRules()也是属性方法。
  8. onClick,事件方法,用来响应Text组件的点击事件。

自定义组件

如上所述,鸿蒙试用报告声明式UI开发组件。

一个简单的例子:

Column() {
    Text("Hello Bro")
    // ...
}

这里包含了一个Column布局和一个Text组件。

正式自定义一个简单的组件:

@Preview
@ComponentV2
export struct ThemedButton {
  @Param message: string = "Hello ThemedButton";

  build() {
    Row() {
      Text(this.message);
    }
    .justifyContent(FlexAlign.Center)
    .alignItems(VerticalAlign.Center)
    .width('100%')
    .height(60)
    .padding(20)
    .borderColor(Color.Blue)
    .borderWidth(1)
    .borderRadius(30)
  }
}

看起来效果是这样的:

QQ_1775389837230.png

在自定义组件的时候可以给组件加一个@Preview的注解,这样可以在IDE的Previewer里看到组件的效果。这样就不需要反复的运行项目才能看到效果了。

@Param是状态管理的一部分,会在后面的状态管理节点细讲。

组件作为参数

使用@BuildParam装饰参数,参数的类型是:() => void。参数可以根据要传入的组件定义。 完成代码:

@Preview
@ComponentV2
export struct Card {
  // ...略...
  @BuilderParam renderContent?: () => void; // 1

  build() {
    Stack({ alignContent: Alignment.TopStart }) {
      Column() {
        if (this.renderContent) {
          this.renderContent()  // 2
        } else {
          Text(this.message)
            .height(20)
            .width(300)
            .align(Alignment.Center)
        }
      }
      // ...略...
    }
    // ...略...
  }
}
  1. 使用@BuildParam装饰要传入的组件,类型是() => void
  2. 在组件的build方法中使用传入的组件。

在其他组件中这样使用:

build() {
    // ...略...
    Column() {
      Card({ title: 'Task pool', message: this.message}) {
        this.renderTaskpool()  // *
      }
    }
  // ...略...

在标记星号的行是一个尾随闭包。换成常规写法是:

Card({ title: 'Task pool', message: this.message, renderContent: this.renderTaskpool()})

复用样式

把几个标准样式放在一起可以定义一个可以复用的样式。

定义一个全局样式

@Styles
function themedBorder() {
  .borderWidth(1)
  .borderColor(Color.Gray)
}

定义一个组件内样式


@Styles
matchParent() {
  .width('100%')
  .height('100%')
}

使用的方法都一样,和标准的属性方法一样:

Column() {
  Text(this.message)
    .height(20)
    .width(300)
    .align(Alignment.Center)
}
.matchParent()
.themedBorder()

renderProps

HOC

组件的生命周期

文档在这里

组件的生命周期: aboutToAppear -> build -> onDidBuild -> aboutToDisappear

  • aboutToAppearbuild方法之前执行,在其中执行初始化组件的任务。不要执行耗时的任务。可以修改状态变量,会在build中起作用。
  • aboutToDisappear在组件销毁前执行,可以在其中执行资源的回收。不可以修改状态变量。

页面的生命周期

页面就是有@Entry装饰的自定义组件。

所以上面说到的自定义组件的生命周期方法都会被调用。额外的还增加了三个生命周期方法: onPageShowonPageHideonBackPress。最后的生命周期方法调用是这样的:

1775396635578_d.png

弹窗

List

Grid

布局

Stack

层叠布局,看起来是这样式儿的

image.png

代码是这样的:

Stack({ alignContent: Alignment.BottomEnd }) { 
  Text('Layer 1')
    .width('100%')
    .height('100%')
    .backgroundColor('#FFE66D')
    .textAlign(TextAlign.Center)
    .fontColor(Color.Black)

  Text('Layer 2')
    .width(150)
    .height(150)
    .backgroundColor('#FF6B6B')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Layer 3')
    .width(80)
    .height(80)
    .backgroundColor('#4ECDC4')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)
}
.width('100%')
.height(200)
.borderRadius(8)
.margin({ top: 20, bottom: 12 })

把颜色,大小等属性方法都删掉,看看关于Stack最重要的部分:

Stack({ alignContent: Alignment.BottomEnd }) {   ///*
  Text('Layer 1')

  Text('Layer 2')

  Text('Layer 3')
}

这里最重要的就是在初始化Stack的时候的参数:alignContentAlignemnt枚举有几个不同的值,分别制定了Stack内的组件的排列顺序。如图:

Flex

和H5的flex基本类似,只是写法换了一下。比如,flex最核心的flex direction和justify content和align items的作用都一样。只是给定值的时候用了鸿蒙自定义的枚举值。

Flex({
  direction: this.flexDirection,
  justifyContent: this.justifyContent,
  alignItems: this.alignItems
}) {
  Text('Item 1')
    .width(80)
    .height(80)
    .backgroundColor('#FF6B6B')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Item 2')
    .width(80)
    .height(80)
    .backgroundColor('#4ECDC4')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)

  Text('Item 3')
    .width(80)
    .height(80)
    .backgroundColor('#45B7D1')
    .textAlign(TextAlign.Center)
    .fontColor(Color.White)
}
.width('100%')
.height(200)
.backgroundColor('#F7F7F7')
.padding(12)

Column和Row

ColumnRow就是Flex这个布局的语法糖。

Column就是FlexDirection的值为Column的时候,Row也一样。

RelativeContainer

这个布局用好了有神奇功效。可以把布局的嵌套减少,提高渲染效率。

看代码:

RelativeContainer() {
  // 左上角元素
  Row()
    // 略
    .id('topLeft')
    .alignRules({        ///*
      top: { anchor: '__container__', align: VerticalAlign.Top },
      left: { anchor: '__container__', align: HorizontalAlign.Start }
    })
    
    // 略
}

RelativeContainer里的组件定位依赖的是相对于哪个组件的定位规则。本例中使用的是anchor: '__container__',也就是相对于容器定位,具体的定位规则是align: VerticalAlign.Top。在父容器的上部分。本例只要给出topleft就可以。

当然,可以相对定位的组件可以是父容器,也可以是容器内地其他组件。或者是参考边界辅助线等。更多可以参考这里

GridRow/GridCol

这个擅长解决不同屏幕尺寸的适配问题。文档在这里

先看效果:

image.png

在折叠屏展开的时候,只显示一行,8列。在折叠屏折叠之后显示2行,4列。

代码:

GridRow({ /// 1
  breakpoints: {  /// 2
    value: ['320vp', '600vp', '840vp', '1440vp',
      '1600vp'], // 表示在保留默认断点['320vp', '600vp', '840vp']的同时自定义增加'1440vp', '1600vp'的断点,实际开发中需要根据实际使用场景,合理设置断点值实现一次开发多端适配。
    reference: BreakpointsReference.WindowSize  /// 3
  },
  columns: { /// 4
    xs: 2, // 窗口宽度落入xs断点上,栅格容器分为2列。
    sm: 4, // 窗口宽度落入sm断点上,栅格容器分为4列。
    md: 8, // 窗口宽度落入md断点上,栅格容器分为8列。
    lg: 12, // 窗口宽度落入lg断点上,栅格容器分为12列。
    xl: 12, // 窗口宽度落入xl断点上,栅格容器分为12列。
    xxl: 12 // 窗口宽度落入xxl断点上,栅格容器分为12列。
  },
}) {
  ForEach(this.bgColors, (color: ResourceColor, index?: number | undefined) => {
    GridCol({ span: 1 }) { // 所有子组件占一列。
      Row() {
        Text(`${index}`)
      }.width('100%').height('50vp')
    }.backgroundColor(color)
  })
}
.height(200)
.border({ color: 'rgb(39,135,217)', width: 2 })
.onBreakpointChange((breakPoint) => {
  this.currentBreakpoint = breakPoint
})
  1. 基本的结构就是外面是GridRow里面是GridCol
  2. breakpoints,也就是断点。其实更适合叫触发点。这里的value定义了一个数组。这里的值定义了屏幕宽度的触发点。屏幕的宽度到了某个值的范围后就会触发一个动作。这个动作在columns定义。
  3. GridRow监听的是哪个组件的宽度,这里是Window的宽度。
  4. columns定义的就是每个宽度对应要显示几列。比如屏幕宽度在xs的时候显示两列,sm宽度显示4列,等。也可以直接给定列数值,那么不管屏幕的宽度如何变化列数也就只显示给定的列数。

默认情况

  1. API version 20之前,columns显示12列。没有设置columns的话,任何断点都是显示12列。
  2. API version 20之后,columns默认值为{ xs: 2, sm: 4, md: 8, lg: 12, xl: 12, xxl: 12 }

初识UIAbility

一个应用可以包含一个或者多个UIAbility。一个UIAbility可以在最近任务中作为一个任务显示。一个Ability可以包含一组界面。所以,使用Ability也可以达到避免加载不必要的资源的效果。

Ability的配置

配置文件module.json5在:

project
└── entry
    └── src
        └── main
            └── ets
                └── module.json5

默认的看起来是这样的:

{
  "abilities": [
    {
      "name": "EntryAbility",
      "srcEntry": "./ets/entryability/EntryAbility.ets",
      "exported": true,
      // 其他略
    }
  ]
}

在这个默认的配置文件中,已经标示了默认生成的Ability的文件位置,名称等。

UIAbility的基本使用

新建一个UIAbility

我们修改已经有的Todo App,把todo详情改成在一个Ability里显示。

在DevEco Studio中,点New->Ability就可以新建一个Ability。这个Ability就叫DetailAbility。也可以手动新建一个,不过要自己在module.json5里添加配置。具体方法可以参考上一节。

新建好之后就可以修改之前nav跳转到Detail页面的代码,这里就要唤起新建的这个Ability了:

.onClick(() => {
  this.isShowFloatingButton(false)
  // this.navPathStack.pushPathByName('detail', item) // 这里是nav跳转的

    const want: Want = {
      deviceId: '',
      bundleName: 'com.example.myapplication',
      abilityName: 'DetailAbility',
      parameters: {
        todo: JSON.stringify(item.toModel())
      }
    };

  (this.getUIContext().getHostContext() as common.UIAbilityContext).startAbility(want)  // 使用startAbility唤起`DetailAbility`
})

使用startAbility唤起DetailAbility的时候,还需要一个Want类型的参数。在这里指定了打开的Ability在哪里是谁。deviceId是空的,说明这个Ability在同一个设备

再新建一个DetailPage,目前这个页面只显示了Detail Page文本。稍后更改这个页面。

注意,这一步不做页面显示白板。在entry/src/main/resources/profile/main_pages.json文档添加新建的这个页面。

{
  "src": [
    "pages/Index",
    "pages/DetailPage"
  ]
}

Ability加载的页面都需要在这里添加配置。

但是,这个打不开Detail页面,默认的加载路径不是Detail页面:

onWindowStageCreate(windowStage: window.WindowStage): void {
  // Main window is created, set main page for this ability
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err) => {  /// ***
    if (err.code) {
      hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
  });
}

windowStage.loadContent方法中第一个参数就是页面的位置,自动生成的时候给的是:pages/Index, 但是我们要打开的是Detail页面。修改页面位置为pages/DetailPage就可以打开了。

效果是这样的:

image.png

一个Ability,对应在任务栏显示一个任务。这里有两个,默认的一个和新建的DetailAbility。

显示todo详情

现在要使用Want中的parameters了,这里传递了一个todo。但是转成了JSON字符串。而且传递的是一个Model实例。

DetailAbilityonCreate中可以拿到这个want的实例,并从parameters中拿到这个json串。反序列化并使用:

const todo = want?.parameters?.['todo'];

if (!todo) {
  hilog.error(DOMAIN, 'testTag', 'todo is null');
  return;
}

const todoInfo = new TodoViewModel( JSON.parse(todo as string) as TodoModel);
AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)

在使用的时候得到model实例,并转成viewmodel后使用。

注意,在DetailPage页面的onPageHide生命周期回调中需要关闭这个Ability:

onPageHide(): void {
  (this.getUIContext().getHostContext() as common.UIAbilityContext).terminateSelf()
}

否则,没有办法在点击其他的todo的时候显示对应的todo的title。详情查看后面的UIAbility数据同步章节。

Ability的生命周期

image.png

onCreate

onWindowStageCreate

这里注意,加载页面是在onWindowStageCreate这个方法进行的:

onWindowStageCreate(windowStage: window.WindowStage): void {
  hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');

  windowStage.loadContent('pages/Index', (err) => { /// *
    if (err.code) {
      hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
      return;
    }
    hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
  });
}

onWindowStageCreate方法中使用windowStage.loadContent('pages/Index', ()=>{})加载了页面。

也可以在这个方法里订阅获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互等事件。详细文档在这里

onForeground

这是Ability的UI可见之前的最后一个回调,在这里申请需要的系统资源。

import { UIAbility } from '@kit.AbilityKit';
// ···

export default class EntryAbility extends UIAbility {
// ···

  onForeground(): void {
    // 申请系统需要的资源,或者重新申请在onBackground()中释放的资源
  }

// ···
}

onBackground

在Ability的UI完全不见之后,触发onBackground回调。开发者可以在这个回调中释放不需要的系统资源,比如停止定位功能等。

onWindowStageWillDestroy

Ability在销毁之前,系统触发这个回调。这个时候WindowStage还没有销毁,还可以用。

onWindowStageWillDestroy(windowStage: window.WindowStage): void {
  // 释放通过windowStage对象获取的资源
  // 在onWindowStageWillDestroy()中注销WindowStage事件订阅(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)
  try {
    if (windowStage) {
      windowStage.off('windowStageEvent');
    }
  } catch (err) {
    let code = (err as BusinessError).code;
    let message = (err as BusinessError).message;
    hilog.error(DOMAIN, 'testTag', `Failed to disable the listener for windowStageEvent. Code is ${code}, message is ${message}`);
  }
}

onWindowStageDestroy

在这里WindowStage还是没有销毁。可以在这里释放UI资源。

onDestroy

UIAbility的最后一个生命周期回调。可以在这里做最后的资源释放,清理等工作。

onNewWant

在Ability实例已经创建,再次调用方法启动这个Ability的时候会触发这个回调。可以在这里更新加载的资源或者数据。

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
// ···

export default class EntryAbility extends UIAbility {
// ···

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam) {
    // 更新资源、数据
  }
}

UIAbility内数据同步

UIAblity和它内部的页面同步数据的方法有两种,一个是使用AppStorageV2(当然PersistenceV2也可以)和事件的方式。

使用AppStorageV2

export default class DetailAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    const todo = want?.parameters?.['todo'];

    if (!todo) {
      hilog.error(DOMAIN, 'testTag', 'todo is null');
      return;
    }

    const todoInfo = new TodoViewModel(JSON.parse(todo as string) as TodoModel);
    AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)
  }
  
  // 其他略
  
}

把want参数的序列化的字符串解析出来后,转成viewmodel然后通过AppStorage实现App全局共享,这样在DetailPage就可以拿到了。

使用EventHub事件的方式

使用EventHubemit方法发出事件,使用EventHubon接收事件。用完之后可以使用off方法取消该事件订阅。

在Ability里接收,在页面发出。

接收:

import { hilog } from '@kit.PerformanceAnalysisKit';
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
// ···

const DOMAIN = 0x0000;
const TAG: string = '[EventAbility]';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 获取eventHub
    let eventhub = this.context.eventHub;
    // 执行订阅操作
    eventhub.on('event1', this.eventFunc);
    eventhub.on('event1', (data: string) => {
      // 触发事件,完成相应的业务操作
    });
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate');
  }

  eventFunc(argOne: object, argTwo: object): void {
    hilog.info(DOMAIN, TAG, '1. ' + `${argOne}, ${argTwo}`);
    return;
  }

// ···
}

发出:

import { common } from '@kit.AbilityKit';

@Entry
@Component
struct EventHubPage {
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  eventHubFunc(): void {
    // 不带参数触发自定义“event1”事件
    this.context.eventHub.emit('event1');
    // 带1个参数触发自定义“event1”事件
    this.context.eventHub.emit('event1', 1);
    // 带2个参数触发自定义“event1”事件
    this.context.eventHub.emit('event1', 2, 'test');
    // 开发者可以根据实际的业务场景设计事件传递的参数
  }

  build() {
    Column() {
      List({ initialIndex: 0 }) {
        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            this.eventHubFunc();
            this.getUIContext().getPromptAction().showToast({
              message: 'EventHubFuncA'
            });
          })
        // ···
        }

        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            this.context.eventHub.off('event1');
            this.getUIContext().getPromptAction().showToast({
              message: 'EventHubFuncB'
            });
          })
        // ···
        }
      }
    // ···
    }
    // ···
  }
}

Ability之间跳转

文档在这里

Ability的冷启动和热启动:

  • 冷启动,就是这个Ability在内存中不存在。这次启动需要完成Ability的初始化和启动的动作。
  • 热启动,这个Ability已经存在于内存中,但是不可见。启动的时候不需要在执行初始化的逻辑,只会触发onNewWant生命周期方法。

启动一个Ability

const want: Want = {
  deviceId: '', // 1
  bundleName: 'com.example.myapplication',
  moduleName: 'entry',  // 2
  abilityName: 'DetailAbility',
  parameters: {
    todo: JSON.stringify(item.toModel())
  }
};
(this.getUIContext().getHostContext() as common.UIAbilityContext).startAbility(want)

接收这些want的数据

  1. deviceId,这里是空,表示本设备
  2. 指定moduleName,非必需。
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  const todo = want?.parameters?.['todo'];

  if (!todo) {
    hilog.error(DOMAIN, 'testTag', 'todo is null');
    return;
  }

  const todoInfo = new TodoViewModel(JSON.parse(todo as string) as TodoModel);
  AppStorageV2.connect<TodoViewModel>(TodoViewModel, 'todo_detail', () => todoInfo)
}

启动一个Ability并获得返回结果

这次使用startAbilityForResult,这个方法可以打开一个Ability并获得返回结果。它返回一个Promise,所以获取返回结果需要用到then,或者使用await

代码:

// 略

@Entry
@Component
struct MainPage {
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  build() {
    Column() {
      List({ initialIndex: 0, space: 8 }) {

        // ···

        ListItem() {
          Row() {
            // ···
          }
          .onClick(() => {
            let context = this.getUIContext().getHostContext() as common.UIAbilityContext; // UIAbilityContext
            const RESULT_CODE: number = 1001;   // 1
            let want: Want = {
              deviceId: '', // deviceId为空表示本设备
              bundleName: 'com.samples.uiabilityinteraction',
              moduleName: 'entry', // moduleName非必选
              abilityName: 'FuncAbilityA',
              parameters: {
                // 自定义信息
                // app.string.main_page_return_info资源文件中的value值为'来自EntryAbility MainPage页面'
                info: $r('app.string.main_page_return_info')
              }
            };
            context.startAbilityForResult(want).then((data) => { // 2
              if (data?.resultCode === RESULT_CODE) {
                // 解析被调用方UIAbility返回的信息
                let info = data.want?.parameters?.info;
                hilog.info(DOMAIN_NUMBER, TAG, JSON.stringify(info) ?? '');
                if (info !== null) {
                  this.getUIContext().getPromptAction().showToast({
                    message: JSON.stringify(info)
                  });
                }
              }
              hilog.info(DOMAIN_NUMBER, TAG, JSON.stringify(data.resultCode) ?? '');
            }).catch((err: BusinessError) => { // 3
              hilog.error(DOMAIN_NUMBER, TAG, `Failed to start ability for result. Code is ${err.code}, message is ${err.message}`);
            });
          })
        }

        // ···
      }
    // ···
    }
    // ···
  }
}
  1. 设置一个返回结果的唯一标识,别接受了别的Ability的返回结果。
  2. 开始一个Ability并处理返回值:startAbilityForResult(want).then((data) => {}
  3. 处理异常。

但是被打开的Ability怎么把结果返回回去呢,使用terminateSelfWithResult()方法。代码:

const RESULT_CODE: number = 1001; // FuncAbilityA返回的结果
let abilityResult: common.AbilityResult = {
  resultCode: RESULT_CODE,
  want: {
    bundleName: 'com.samples.uiabilityinteraction',
    moduleName: 'entry', // moduleName非必选
    abilityName: 'FuncAbilityA',
    parameters: {
      // app.string.ability_return_info资源文件中的value值为'来自FuncAbility Index页面'
      info: $r('app.string.ability_return_info')
    },
  },
};
context.terminateSelfWithResult(abilityResult, (err) => {
  if (err.code) {
    hilog.error(DOMAIN_NUMBER, TAG, `Failed to terminate self with result. Code is ${err.code}, message is ${err.message}`);
    return;
  }
});

状态管理

状态管理上,有V1和V2两个版本。推荐使用的是V2版本。在具体的开发中结合MVVM模式使用。

V1

V2

以下的装饰器都只能在ComponentV2装饰的组件内部使用。@ObservedV2@Trace除外。

@Local

只在组件内部使用。

@Entry
@ComponentV2
struct Index {
  // 点击的次数
  @Local count: number = 0;
  @Local message: string = 'Hello';
  @Local flag: boolean = false;

  build() {
    Column() {
      Text(`${this.count}`)
      Text(`${this.message}`)
      Text(`${this.flag}`)
      Button('change Local')
        .onClick(() => {
          // 当@Local装饰简单类型时,能够观测到对变量的赋值
          this.count++;
          this.message += ' World';
          this.flag = !this.flag;
        })
    }
  }
}

@Param

接收付组件传入的值。

// 子组件
@ComponentV2
struct Child {
  @Param count: number = 0;         /// 1
  @Require @Param message: string;
  @Require @Param flag: boolean;

  build() {
    Column() {
      Text(`Param ${this.count}`)
      Text(`Param ${this.message}`)
      Text(`Param ${this.flag}`)
    }
  }
}

// 父组件
@Entry
@ComponentV2
struct Index {
  // 点击的次数
  @Local count: number = 0;
  @Local message: string = 'Hello';
  @Local flag: boolean = false;

  build() {
    Column() {
      Text(`Local ${this.count}`)
      Text(`Local ${this.message}`)
      Text(`Local ${this.flag}`)
      Button('change Local')
        .onClick(() => {
          // 对数据源的更改会同步给子组件
          this.count++;
          this.message += ' World';
          this.flag = !this.flag;
        }) 
      Child({   /// 2
        count: this.count,
        message: this.message,
        flag: this.flag
      })
    }
  }
}

@Event

接收父组件传入的方法,用来更新父组件的数据源。

// 子组件
@ComponentV2
struct Child {
  @Param title: string = '';
  @Param fontColor: Color = Color.Black;
  @Event changeFactory: (x: number) => void = (x: number) => {}; /// 1

  build() {
    Column() {
      Text(`${this.title}`)
        .fontColor(this.fontColor)
      Button('change to Title Two')
        .onClick(() => {
          this.changeFactory(2); /// 2
        })
      Button('change to Title One')
        .onClick(() => {
          this.changeFactory(1);
        })
    }
  }
}

// 父组件
@Entry
@ComponentV2
struct Index {
  @Local title: string = 'Title One';
  @Local fontColor: Color = Color.Red;

  build() {
    Column() {
      Child({
        title: this.title,
        fontColor: this.fontColor,
        changeFactory: (type: number) => { /// 3
          if (type == 1) {
            this.title = 'Title One';
            this.fontColor = Color.Red;
          } else if (type == 2) {
            this.title = 'Title Two';
            this.fontColor = Color.Green;
          }
        }
      })
    }
  }
}
  1. 在子组件定义一个@Event回调。
  2. 在子组件调用这个回调。
  3. 在父组件传递一个箭头函数给子组件定义的。

在父组件给出的定义中,子组件调用这个回调的时候可以修改父组件的titlefontColor两个数据。

@ObservedV2和@Trace

用于监视对象内部属性的变化。

在上面描述的装饰器中,如果是一个对象,那么只能监听到赋值的变化。如果是一个数组等数据集合,一般只能监听到集合内部API的调用引起的变化。如果修改了数据某个对象的属性的值,是无法监听到的,也就是这样的修改不会出现在界面上。

这就需要用到@ObservedV2@Trace的装饰器组合。

  1. @ObservedV2装饰类。
  2. @Trace装饰需要观察的属性。

注意@ObservedV2装饰的类要在new出来之后才有观察变化的能力。

代码:

@ObservedV2  /// 1
class Son {
  @Trace public age: number = 100; /// 2
}

class Father {
  public son: Son = new Son();  /// 3
}

@Entry
@ComponentV2
struct Index {
  father: Father = new Father();   /// 4

  build() {
    Column() {
      // 当点击改变age时,Text组件会刷新
      Text(`${this.father.son.age}`)  /// 5
        .onClick(() => {
          this.father.son.age++;  /// 6
        })
    }
  }
}
  1. 使用@ObservedV2装饰类。
  2. 使用@Trace装饰需要监听的属性age
  3. @Observed装饰的类在另一个类力使用。不是使用的必要步骤,只是说明监听的穿透力强
  4. 初始化需要监听的类。必须初始化才可以监听到变化。
  5. 如果属性的值发生变化,而且被监听到了,那么在界面上显示对应的变化。
  6. 在点击事件中更改@Trace装饰的属性。

@Provider / @Consumer

跨组件双向同步数据。遇到同名的时候,使用组件树上最近的那个。

定义的时候是这样的:

  1. @Provider(aliasName?: string) varName : varType = initValuealiasName为空的时候使用属性名作为aliasName
  2. @Consumer(aliasName?: string) varName : varType = initValuealiasName,如果为空就是属性名,是@Provider@Consumer关联的唯一key值。
@ComponentV2
struct Parent {
  // 未定义aliasName, 使用属性名'str'作为aliasName
  @Provider() str: string = 'hello';
}

@ComponentV2
struct Child {
  // 定义aliasName为'str',使用aliasName去寻找
  // 能够在Parent组件上找到, 使用@Provider的值'hello'
  @Consumer('str') str: string = 'world';
}
  1. 这两个需要在同一个组件树中,在这个组件树的不同层级双向同步数据。
  2. 如果@Consumer找不到对应的@Provider,则使用本地的默认值。

@Monitor

装饰一个方法,可以在装饰器参数指定监视的状态。被监听的状态变化的时候触发Monitor装饰的方法。


import { hilog } from '@kit.PerformanceAnalysisKit';

@Entry
@ComponentV2
struct Index {
  @Local message: string = 'Hello World';
  @Local name: string = 'Tom';
  @Local age: number = 24;

  @Monitor('message', 'name')
  onStrChange(monitor: IMonitor) {
    monitor.dirty.forEach((path: string) => {
      hilog.info(0xFF00, 'testTag', '%{public}s',
        `${path} changed from ${monitor.value(path)?.before} to ${monitor.value(path)?.now}`);
    });
  }

  build() {
    Column() {
      Button('change string')
        .onClick(() => {
          this.message += '!';
          this.name = 'Jack';
        })
    }
  }
}

也可以监听被@Trace装饰的属性的变化:

监听@Trace装饰的属性
@Monitor('info')
infoChange(monitor: IMonitor) {
  hilog.info(0xFF00, 'testTag', '%{public}s', `info change`);
}

@Monitor('info.name') ///*
infoPropertyChange(monitor: IMonitor) {
  hilog.info(0xFF00, 'testTag', '%{public}s', `info name change`);
}

属性name@Trace装饰的时候可以被@Monitor监听到。

监听多个
@Monitor('region', 'job') /// *
onChange(monitor: IMonitor) {
  monitor.dirty.forEach((path: string) => {
    hilog.info(0xFF00, 'testTag', '%{public}s',
      `${path} change from ${monitor.value(path)?.before} to ${monitor.value(path)?.now}`);
  })
}
可以在@ObservedV2装饰的类中使用

@Computed

装饰一个get属性,从一个或者多个获取到一个最终值。避免多个状态变化是多次计算。只做读,不做写

@Computed
get sum() {
  return this.count1 + this.count2 + this.count3;
}

@Type

你的类需要被序列化,这时你的类里面还定义了一个属性,这个属性的类型也是一个类。为了不在序列化的时候丢失这个属性的类型可以用@Type来装饰属性。

注意:

  1. 构造函数不包含参数
  2. 一般配合PersistenceV2一起使用
class Sample {
  private data: number = 0;
}

@ObservedV2
class Info {
  @Type(Sample)
  @Trace public sample: Sample = new Sample(); // 正确用法
}

@AppStorageV2

整个app的状态管理,可以跨UIAbility共享数据。

定义:

AppStorageV2.connect(/* 参数 */)

代码:

AppStorageV2.connect<ThemeModel>(ThemeModel, 'app_theme', () => new ThemeModel(initialColorModel))
connect

这个方法用来创建或者获取存储的数据。

connect的参数。第一个是存放的类型。第二个可以是key,也可以是类型的默认构造器。如果第二个不是默认构造器,或者第二个参数不合法,那么第二个必须是默认构造器。

remove

使用remove删除指定的key对应的数据

keys

使用keys可以得到AppStorageV2中的全部key。

注意AppStorageV2只支持class类型。只能在UI线程使用。不支持collections.Setcollections.Map等类型。

@PersistenceV2

整个app的状态管理,另外带有持久化功能。

AppStorageV2一样,只是这个可以持久化存储。在第二次打开app的时候对应的状态不会丢失。

MVVM

App的代码组织模式。

简单的应用可以使用这样的模式来实现。

基本的组成有:

  1. Model层,负责管理数据。
  2. ModelView层,连接Model和View,负责管理UI的状态和业务逻辑。通过监控Model数据的变化,处理业务逻辑并将数据更新到View层。
  3. UI层,展示数据和与用户交互。

另外还有对数据库或者服务器操作的repository层。

示例代码在这里。作为一个Todo类App的Model层,这里定义了TodoModelTodoListModel

Model

TodoModel中,只负责持有数据。在TodoListModel中则需要借助repository处理本地SQLite的数据。入:

import { TodoModel } from './TodoModel';
import { TodoRepository } from '../libs/repository';

export class TodoListModel {
  private _todos: TodoModel[] = []; /// 1
  private _repo?: TodoRepository;   /// 2

  // 略

  async loadTodoList() {
    if (!this._repo) {
      this._repo = await TodoRepository.getInstance(this._context)
    }

    const todoList = await this._repo?.queryAll()  /// 3
    this._todos = todoList ?? []
  }

  // 其他略
}
  1. TodoListModel的数据,一组TodoModel实例。
  2. TodoRepository,用来管理本地数据库的数据。
  3. 使用repository获取数据。

ModelView

import { TodoModel } from '../model/TodoModel'

@ObservedV2
export class TodoViewModel {
  id: number = 0;
  createdAt: number = Date.now();
  updatedAt: number = Date.now();
  @Trace title: string = ''; // 标题
  @Trace description: string = ''; // 描述
  @Trace completed: boolean = false; // 完成状态
  @Trace version: number = 0; // 版本(用于乐观锁)
  
  // 其他略
}  

这里就使用了@ObservedV2@Trace来监听数据的变化。

import { Type } from '@kit.ArkUI';
import { TodoListModel } from '../model/TodoListModel';
import { TodoModel } from '../model/TodoModel';
import { TodoViewModel } from './TodoViewModel';

@ObservedV2
export class TodoListViewModel {
  @Trace todoList: TodoViewModel[] = [];
  private _todoListModel?: TodoListModel

  // TODO: Add error message for display
  
  // 略

  async addTodo(title: string, description: string = '') {
    const todo: TodoModel = new TodoModel({ title, description });
    await this._todoListModel?.addTodo(todo);
    this.todoList.push(new TodoViewModel(todo));
  }
 
  // 其他略
}

TodoListViewModel的Model数据就是_todoListModel,并使用@Trace进行深度监听。

UI / View

@Entry
@ComponentV2
struct Index {
  // 略
  
  @Local todoList: TodoListViewModel = new TodoListViewModel(this.getUIContext().getHostContext())
  
  // 其他略
  
}

在视图中把TodoListViewModel的示例使用@Local装饰。这样就把ViewModel这个模式的各个要素串联到了一起。

导航

这里介绍Stack导航和Tab导航。

Navigation

Navigation四件套:

  1. Navigation组件,这个必不可少。
  2. NavPathStack实例。实际控制导航到哪里。
  3. 页面映射关系。这个定义在一个@Builder装饰的方法里。
  4. NavDestination。导航的目标“页”中最外面的组件就是NavDestination

代码如下:

// ...

@Entry
@ComponentV2
struct Index {
  navStack: NavPathStack = new NavPathStack()  // 1

  build() {
    Navigation(this.navStack) {   // 2
      Column() {
        ThemedButton({ message: '弹窗' })
          .padding(10)
          .onClick(() => {
            this.navStack.pushPath({ name: 'Pops' }) // 3
          })
      }
      // ...
    }
    .navDestination(this.pageMap)  // 4
  }

  @Builder
  pageMap(name: string) {  // 5
    if (name === "Pops") {
      PopupSamples()
    }
  }
}

目标页:

@Preview
@ComponentV2
export struct PopupSamples {
  build() {
    NavDestination() {  、、 6
      Column() {
        Text('PopupSamples')
      }
    }
  }
}
  1. NavPathStack,作为页面的成员初始化。
  2. Navigation,这就应该出现了,在布局顶层。并把NavPathStack的实例作为成员传入。这两个就关联在一起了。
  3. 使用NavPathStack成员执行导航。
  4. Navigation的属性方法中配置可以导航的页面。
  5. 定义页面地图。映射导航的名称和对应的组件。

更多高级内容稍后补充。。。

Tab

@Preview
@ComponentV2
export struct TabsSamples {
  build() {
    NavDestination() {
      Tabs({barPosition: BarPosition.End}) { // 1
        TabContent() {                       // 2
          Text('Tab 1')
        }
        .tabBar("Tab 1")                     // 3

        TabContent() {
          Text('Tab 2')
        }
        .tabBar("Tab 2")
      }
    }
  }
}

Tab布局的实现需要三件套: TabsTabContenttabBar属性方法。

上面的代码中:

  1. Tabs,Tab布局的总体设置都在这里。比如在上面的例子中配置tab bar的位置在底部。
  2. TabContent,每个tab的内容容器。上例的内容为一个Text组件。
  3. tabBar,在这配置tab按钮

访问网络

首先,可以使用如下代码实现一个极简的服务器,get请求后可以返回一个json串来验证鸿蒙网络请求正确与否。

如下的bash命令直接在terminal运行即可跑起来一个server。

node版本:

node -e "require('http').createServer((req, res) => { res.writeHead(200, {'Content-Type': 'application/json'}); res.end(JSON.stringify({status: 'ok'})); }).listen(8000); console.log('Server running on port 8000')"

python:

python3 -c "from http.server import HTTPServer, BaseHTTPRequestHandler; import json; class S(BaseHTTPRequestHandler): do_GET = do_POST = lambda s: (s.send_response(200), s.send_header('Content-Type', 'application/json'), s.end_headers(), s.wfile.write(json.dumps({'status': 'ok'}).encode())); HTTPServer(('0.0.0.0', 8000), S).serve_forever()"

也可以直接使用这个地址:

https://jsonplaceholder.typicode.com/posts/1

使用http模块

async sendHttpRequest() {
  this.httpLoading = true
  this.httpResponseText = ''

  try {
    const httpRequest = http.createHttp() /// 1
    const url = 'https://jsonplaceholder.typicode.com/posts/1'

    const response = await httpRequest.request(url, { /// 2
      method: http.RequestMethod.GET,
      header: {
        'Content-Type': 'application/json'
      },
      connectTimeout: 60000,
      readTimeout: 60000
    })

    if (response.responseCode === 200) {
      this.httpResponseText = JSON.stringify(JSON.parse(response.result as string), null, 2)
    } else {
      this.httpResponseText = `请求失败: HTTP ${response.responseCode}`
    }

    httpRequest.destroy()  /// 3
  } catch (error) {
    this.httpResponseText = `请求出错: ${JSON.stringify(error)}`
  } finally {
    this.httpLoading = false
  }
}
  1. 使用http.createHttp()新建一个http请求实例:httpRequest
  2. 使用httpRequest请求服务器,并配置http method,请求的url地址等。
  3. 最后要销毁httpRequest

使用Axios访问网络:

async sendAxiosRequest() {
  this.axiosLoading = true
  this.axiosResponseText = ''

  try {
    const response:AxiosResponse<string> = await axios.get('https://jsonplaceholder.typicode.com/posts/1', {
      headers: {
        'Content-Type': 'application/json'
      },
      timeout: 60000
    })   /// 1

    this.axiosResponseText = JSON.stringify(response.data, null, 2)
  } catch (error) {
    this.axiosResponseText = `请求出错: ${JSON.stringify(error)}`
  } finally {
    this.axiosLoading = false
  }
}

使用axios就简单多了。直接使用axios.get请求。

数据存储

SQLite, 四个读连接,一个写连接。

代码:


import { relationalStore, ValuesBucket } from '@kit.ArkData'
import { Context } from '@kit.AbilityKit'
import { TodoModel } from '../model/TodoModel'

export const DB_NAME = 'todo_db.db'

export const DB_VERSION = 1

export const CREATE_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS todo_info (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    completed INTEGER DEFAULT 0,
    version INTEGER DEFAULT 1,
    createdAt INTEGER,
    updatedAt INTEGER
  )
`

export const STORE_CONFIG: relationalStore.StoreConfig = {
  name: DB_NAME,
  securityLevel: relationalStore.SecurityLevel.S1
}

export class TodoRepository {
  private static instance: TodoRepository
  private rdbStore: relationalStore.RdbStore | null = null
  private context: Context | null = null

  static async getInstance(context?: Context): Promise<TodoRepository> {
    if (!TodoRepository.instance) {
      if (!context) {
        throw new Error('Context must be provided for first initialization')
      }
      TodoRepository.instance = new TodoRepository()
      await TodoRepository.instance.init(context)
    }
    return TodoRepository.instance
  }

  private async init(context: Context): Promise<void> {
    this.context = context
    this.rdbStore = await relationalStore.getRdbStore(context, STORE_CONFIG)
    await this.rdbStore.executeSql(CREATE_TABLE_SQL)
  }

  async queryAll(): Promise<TodoModel[]> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const resultSet = await this.rdbStore.querySql('SELECT * FROM todo_info ORDER BY createdAt DESC')
    const list: TodoModel[] = []
    while (resultSet.goToNextRow()) {
      const todo = new TodoModel()
      todo.id = resultSet.getLong(resultSet.getColumnIndex('id'))
      todo.title = resultSet.getString(resultSet.getColumnIndex('title'))
      todo.description = resultSet.getString(resultSet.getColumnIndex('description'))
      todo.completed = resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
      todo.createdAt = resultSet.getDouble(resultSet.getColumnIndex('createdAt'))
      todo.updatedAt = resultSet.getDouble(resultSet.getColumnIndex('updatedAt'))

      list.push(todo)
    }
    resultSet.close()
    return list
  }

  async getTodoById(id: number): Promise<TodoModel | null> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const predicates = new relationalStore.RdbPredicates('todo');
    predicates.equalTo('id', id);

    const columns = ['id', 'title', 'description', 'completed', 'version', 'createdAt', 'updatedAt'];
    const result = await this.rdbStore.query(predicates, columns);

    if (result.goToNextRow()) {
      const todo = TodoModel.fromDatabase({
        id: result.getDouble(result.getColumnIndex('id')),
        title: result.getString(result.getColumnIndex('title')),
        description: result.getString(result.getColumnIndex('description')),
        completed: result.getDouble(result.getColumnIndex('completed')),
        version: result.getDouble(result.getColumnIndex('version')),
        createdAt: result.getDouble(result.getColumnIndex('createdAt')),
        updatedAt: result.getDouble(result.getColumnIndex('updatedAt'))
      });
      result.close();
      return todo;
    }

    result.close();
    return null;
  }

  async insert(todo: TodoModel): Promise<void> {
    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    const valueBucket: ValuesBucket = {
      // id: todo.id,
      title: todo.title,
      description: todo.description,
      completed: todo.completed ? 1 : 0,
      createdAt: todo.createdAt
    }
    await this.rdbStore.insert('todo_info', valueBucket)
  }

  async updateStatus(todo: TodoModel): Promise<void> {
    const updateStatement = 'UPDATE todo_info SET ' + todo.toKeyValuePairs() + ` WHERE id = ${todo.id}}`

    if (!this.rdbStore) {
      this.rdbStore = await relationalStore.getRdbStore(this.context!, STORE_CONFIG)
    }

    await this.rdbStore.executeSql(updateStatement)
  }

  async deleteTodo(id: number): Promise<boolean> {
    if (!this.rdbStore) {
      throw new Error('数据库未初始化');
    }

    const predicates = new relationalStore.RdbPredicates('todo_info');
    predicates.equalTo('id', id);

    const affectedRows = await this.rdbStore.delete(predicates);
    return affectedRows > 0;
  }
}

通知和推送

异步编程

有两种方式实现,一个是异步并发使用promise和async/await实现。依靠单线程的事件循环。

另外一种就是多线程并发。使用TaskPool和Worker实现。

异步并发

使用Promise或者async/await实现。如:

const promise: Promise<number> = new Promise((resolve: Function, reject: Function) => {
  setTimeout(() => {
    const randomNumber: number = Math.random();
    if (randomNumber > 0.5) {
      resolve(randomNumber);
    } else {
      reject(new Error('Random number is too small'));
    }
  }, 1000);
})

Async/Await

async function myAsyncFunction(): Promise<string> {
  const result: string = await new Promise((resolve: Function) => {
    setTimeout(() => {
      resolve('Hello, world!');
    }, 3000);
  });
  console.info(result); // 输出: Hello, world!
  return result;
}

多线程并发

多线程并发可以使用taskpoolWorker实现。

Taskpool

底层原理是Actor模型。开发中可以使用TaskPool或者Worker实现。详细的文档可以看这里

简言之,Actor模型的多个线程之间不同享内存,一个线程就是一个Actor。多个线程需要通信则互发消息。

taskpool的典型用法,看代码:

import { taskpool } from '@kit.ArkTS';
import { sleep } from '../utils';

@Concurrent  // => 1
async function generateNumber(ms: number): Promise<number> {
  await sleep(ms);
  return Math.random();
}

@ComponentV2
export struct AsyncSamples {
  @Local message: string = 'Async Samples';

  aboutToAppear(): void {
    const task = new taskpool.Task(generateNumber, 2000); // => 2
    taskpool.execute(task).then((result) => { // => 3
      console.log('Task result', result);
    });
  }

  build() {
    NavDestination() {
      Column() {
        Text(this.message)
      }
      // ...
    }
  }
}

注意:这里只说鸿蒙文档的典型用法,其他用法在后面会提到。

上面的代码,首先引入taskpool

  1. 定义一个并发方法,通过@Concurrent这个装饰器装饰一个方法实现。这个方法可以是一个async方法,也可以不是。
  2. 用定义好的并发方法新建一个taskpool.Task实例。如果这个并发方法需要一个参数,那么在定义Task的时候在第二个参数给出。
  3. 使用taskpool.execute执行前一步定义好的task。在then里获取执行的结果,并更新组件的状态。task的结果就显示在界面上了。

在定义并发方法的时候,方法内部使用的只能是局部变量,入参和import引入的变量。

taskpool也可以这样:

function someFunc(param: string) {
    //...
}

taskpool.execute(someFunc, param).then((ret) => { // 定期执行可以使用executePeriodically
    // ...
});

Worker

Worker实现三步:

  1. 新建一个新的文件放worker的代码。
  2. 新建worker.ThreadWorker实例。
  3. 宿主现场和子线程之间互传消息。

具体实现如下:

c343b933-91db-42c3-b2d6-becd4eda75b3.png

点了Start worker之后开始执行线程代码。每次更新progress bar的进度,一直到达到百分之百。

代码如下:

import { MessageEvents, ThreadWorkerGlobalScope, worker } from '@kit.ArkTS';

const workerPort: ThreadWorkerGlobalScope = worker.workerPort; /// 1

let progressTimer: number | null = null
let currentProgress: number = 0

workerPort.onmessage = (e: MessageEvents) => {  /// 2
  const message:string = e.data

  if (message=== 'START') {
    currentProgress = 0

    // 清除旧定时器
    if (progressTimer) {
      clearInterval(progressTimer)
    }

    // 每 300ms 发送进度
    progressTimer = setInterval(() => {
      currentProgress += 2  // 每次增加 2%

      // 发送进度回主线程
      workerPort.postMessage({                  /// 3
        type: 'PROGRESS_UPDATE',
        value: currentProgress
      })

      // 完成时清理
      if (currentProgress >= 100) {
        if (progressTimer) {
          clearInterval(progressTimer)
          progressTimer = null
        }
        workerPort.postMessage({ type: 'COMPLETED' })
      }
    }, 300)
  }

  if (message=== 'STOP') {
    if (progressTimer) {
      clearInterval(progressTimer)
      progressTimer = null
    }
  }
}
  1. 初始化workerPost实例,这是线程互通的关键。
  2. 接受主线程的消息,根据STARTSTOP开始或者停止发送消息。
  3. 在本线程给主线程发送消息:workerPort.postMessage

在UI:

ThemedButton({
  message: this.progressText, handleClick: () => {
    let workerInstance = new worker.ThreadWorker('entry/ets/workers/worker.ets'); /// 1
    
    workerInstance.postMessage('START');             /// 2
    workerInstance.onmessage = ((e: MessageEvents) => {   /// 3
      const data: WorkerMessage = e.data as WorkerMessage;
      if (data.type === 'PROGRESS_UPDATE') {
        this.progressValue = data.value;
        this.status = 'running';
      } else if (data.type === 'COMPLETED') {
        this.progressValue = 100;
        this.status = 'completed';
      }
    });
  }
})
  1. 使用定义好的worker.ets文件新建worker实例。
  2. 给子线程发送消息:workerInstance.postMessage()。这里通知子线程开始执行。
  3. 接收子线程发送过来的消息,并更新界面。

注意:在处理worker文件的时候有些注意事项,请看这里

TaskpoolWorker的对比,看这里

ArkUI List 图片拖动排序最佳实践

ArkUI List 拖动排序最佳实践

在 ArkUI 中实现列表拖动排序,主要依赖 List 组件的 onMove 回调。结合 @Local 的响应式更新,几行代码就能实现功能。

数据层

@Local imageUris: Array<string> = [];

渲染层

List() {
  ForEach(this.imageUris, (uri: string, index: number) => {
    ListItem() {
      Stack() {
        Image(uri)
          .width('100%')
          .height(150)
          .objectFit(ImageFit.Cover)
          .borderRadius(10)

        Stack()
          .width('100%')
          .height(150)
      }
    }
    .margin(10)
    .borderRadius(10)
    .backgroundColor('#FFFFFFFF')
  }, (uri: string) => uri)
    .onMove((from: number, to: number) => {
      let tmp = this.imageUris.splice(from, 1);
      this.imageUris.splice(to, 0, tmp[0]);
    })
}

onMove 回调

onMove 是整个拖动排序的核心。当手指长按某个 ListItem 并拖动到新位置时,系统会触发这个回调,并传入两个索引:被拖动项的原位置 from,和目标位置 to

这里的实现利用了数组的 splice 方法两步走:

  1. splice(from, 1) — 从原位置切出被拖动的元素
  2. splice(to, 0, ...) — 将其插入到目标位置

由于数组引用没有变化(splice 是 in-place 操作),@Local 的响应式驱动需要依赖 ArkUI 的代理对象机制。在 ForEach 中使用稳定的 key 配合 splice 操作,框架能正确追踪数组变化并触发最小粒度的 UI 更新。

如果需要保留排序结果到持久化存储,比如 UserInfo 或数据库,可以在 onMove 回调末尾追加对应的保存逻辑。

图片选择

配合系统 PhotoViewPicker,最多选 20 张:

async selectImages(): Promise<void> {
  const photoPicker = new photoAccessHelper.PhotoViewPicker();
  const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
  photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  photoSelectOptions.maxSelectNumber = 20;

  const photoSelectResult = await photoPicker.select(photoSelectOptions);
  if (photoSelectResult?.photoUris?.length > 0) {
    this.imageUris = [...this.imageUris, ...photoSelectResult.photoUris];
  }
}

组件层级

List (onMove 接收拖动事件)
├── ListItem (长按触发拖动)
│   └── Stack (布局容器)
│       ├── Image (图片展示)
│       └── Stack (透明覆盖层,透传手势)
└── ListItem ...

注意事项

  • 透明覆盖层 — 当 ListItem 内只包含 Image 时,Image 组件会优先处理手势,导致 onMove 无法触发。在 Image 上方添加一个透明 Stack 铺满父容器尺寸,将手势透传给 ListItem。Stack 默认裁剪超出内容,圆角设置不受影响。
  • ForEach 需要稳定的 key — 使用 (uri: string) => uri 作为唯一标识,避免排序时列表项渲染错乱。

【Flutter×鸿蒙】通关手册(二):FVM 不认鸿蒙 SDK?4步手动塞进去

系列导航:

我第一次让 FVM 管理鸿蒙版 Flutter SDK 时,前后踩了 4 个坑,花了大半天才跑通。事后复盘发现,每个坑都不难,只是没人提前告诉我"为什么要这样做"。这篇把整个过程拆成 5 关,每关讲清「为什么」和「怎么做」,争取让你 20 分钟一次通关。

前置条件:请先完成第一篇的全部内容——DevEco Studio 已安装,ohpm、node、hvigorw 在终端里都能正常调用。


🗺️ 通关路线图

关卡 任务 预计耗时
第1关 安装 FVM 2 min
第2关 克隆鸿蒙版 SDK 5 min(取决于网速)
第3关 修复版本"身份证" 3 min
第4关 指定鸿蒙 SDK 路径 1 min
第5关 全绿验证 2 min

🎯 第 1 关:安装 FVM

目标

让终端认识 fvm 命令。

为什么需要 FVM

一句话——让不同项目用不同版本的 Flutter,互不干扰。比如项目 A 用官方 3.24 跑 Android/iOS,项目 B 用鸿蒙版 3.35.8。FVM 就是 Flutter 的"版本档案柜",每个抽屉放一个版本。

📋 操作

# macOS(在终端里执行,这是用 Homebrew 包管理器安装 FVM)
brew install fvm
# Windows(在 cmd 或 PowerShell 中执行,这是用 Chocolatey 包管理器安装 FVM)
choco install fvm

安装完后,配置 FVM 缓存路径。把以下两行写入 ~/.zshrc(上一篇介绍过,这是 Mac 终端的配置文件):

# FVM 存放所有 Flutter 版本的目录
export FVM_CACHE_PATH=$HOME/fvm
# 让 FVM 的默认版本可以直接用 flutter 命令调用
export PATH="$HOME/fvm/default/bin:$PATH"

保存后执行下面这条命令,让刚才的配置立即生效(否则要关掉终端重新打开):

source ~/.zshrc

✅ 验证

# 查看 FVM 版本号,确认安装成功
fvm --version

看到版本号(如 3.1.4)就过关了。

⚠️ 如果报 command not found:Mac 用户确认已安装 Homebrew(执行 brew --version 看有没有输出);Windows 用户确认已安装 Chocolatey(执行 choco --version)。如果包管理器本身都没装,请先去官网安装。


🎯 第 2 关:克隆鸿蒙版 SDK

目标

把华为的鸿蒙版 Flutter 放进 FVM 管辖。

为什么不能直接 fvm install

正常装 Flutter 只需要 fvm install 3.24.0,FVM 会自动去 GitHub 下载。但鸿蒙版是华为团队在 AtomGit(国内代码托管平台)上单独维护的,FVM 的世界里它根本不存在。所以我们要"手动入库"——自己下载代码,放到 FVM 的档案柜里,假装它一直在那。

⚠️ 本关最大的坑:分支名和版本号是两回事!

仓库的分支叫 oh-3.35.7-dev,看到 3.35.7 你会以为版本就是 3.35.7。但实际上代码里的版本已经迭代到了 3.35.8-ohos-0.0.2

类比:Git 分支叫 feature/login-v1,但代码早就改到 v3 了。分支名是创建时起的,不会跟着版本号自动更新。

千万别拿分支名当版本号用,团队必须统一用 3.35.8-ohos-0.0.2 这个真实版本号。

📋 操作

# --depth 1 只取最新代码,省空间(省去几个 GB 的历史记录)
git clone -b oh-3.35.7-dev --depth 1 
https://atomgit.com/openharmony-tpc/flutter_flutter.git 
~/fvm/versions/3.35.8-ohos-0.0.2

注意看:clone 命令里分支名是 oh-3.35.7-dev,但目标文件夹名是 3.35.8-ohos-0.0.2——这不是写错了,上面已经解释了为什么不一样。

💡 怎么确认真实版本号? clone 完后执行 ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter --version 看输出。如果加入已有团队,直接看项目的 .fvmrc 文件(命令:cat .fvmrc)。

✅ 验证

# 确认文件下载成功(ls = 列出目录内容)
ls ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter

文件存在就过关。

⚠️ 如果报 No such file or directory:回去检查 clone 命令是否执行成功。常见原因是网络超时(AtomGit 在国内,通常不需要梯子,但公司内网可能有限制)。重新执行 clone 前,先删掉残留目录:rm -rf ~/fvm/versions/3.35.8-ohos-0.0.2,再重试。


🎯 第 3 关:修复版本"身份证"

目标

让 FVM 正确识别这个 SDK 的版本号。

为什么要做这步

clone 下来的 SDK 有两张"证件":

  1. version 文件——相当于身份证,一行文本写着版本号
  2. bin/cache/flutter.version.json——相当于内部档案,JSON 格式的详细版本信息

问题是,这两张证件上都写着 0.0.0-unknown(因为鸿蒙团队是从开发分支构建的,没有打标准标签)。但我们的文件夹名叫 3.35.8-ohos-0.0.2。FVM 一查——名字对不上,直接翻脸。

⚠️ 不做这步的后果:FVM 会弹出 "Version mismatch" 并试图删掉你的 SDK 重装。如果看到了这个弹窗,千万不要选任何选项,按 Ctrl+C(Mac 也是 Ctrl 不是 Cmd)退出,回来做这步。

📋 操作

macOS / Linux:

cd ~/fvm/versions/3.35.8-ohos-0.0.2

# 第一步:改"身份证"
echo -n "3.35.8-ohos-0.0.2" > version

# 第二步:初始化 Flutter 引擎(首次运行会下载 Dart SDK,需要等 1-3 分钟)
bin/flutter --version

# 第三步:改"内部档案"(把所有 0.0.0-unknown 替换成正确的版本号)
sed -i '' 's/0.0.0-unknown/3.35.8-ohos-0.0.2/g' bin/cache/flutter.version.json

Windows PowerShell:

# 进入 SDK 所在目录
cd $env:USERPROFILE\fvm\versions\3.35.8-ohos-0.0.2

# 第一步:改"身份证"
"3.35.8-ohos-0.0.2" | Set-Content version -NoNewline

# 第二步:初始化引擎
bin\flutter --version

# 第三步:改"内部档案"(PowerShell 的查找替换写法)
(Get-Content bin\cache\flutter.version.json) -replace '0.0.0-unknown', '3.35.8-ohos-0.0.2' | Set-Content bin\cache\flutter.version.json

⚠️ 三步的顺序不能乱——第二步会生成 flutter.version.json 文件,第三步才有东西可改。如果你先执行了第三步,会报文件不存在。

✅ 验证

# 回到任意目录都可以执行(fvm list = 列出 FVM 管理的所有 Flutter 版本)
fvm list

看到 Version 列显示 3.35.8-ohos-0.0.2(不是空白、不是 Need setup、不是 0.0.0-unknown),这关就过了。

02_fvm_list.png ⚠️ 如果还是显示异常,逐一排查两张"证件":

# 检查"身份证"内容
cat ~/fvm/versions/3.35.8-ohos-0.0.2/version
# 应该输出:3.35.8-ohos-0.0.2(没有多余空行)

# 检查"内部档案"有没有残留的 0.0.0-unknown
cat ~/fvm/versions/3.35.8-ohos-0.0.2/bin/cache/flutter.version.json
# 里面所有 version 字段应该都是 3.35.8-ohos-0.0.2

如果 version 文件内容不对,重新执行第一步;如果 JSON 里还有 0.0.0-unknown,重新执行第三步。


🎯 第 4 关:指定鸿蒙 SDK 路径

目标

让 Flutter 知道鸿蒙的 SDK(OpenHarmony SDK)装在哪。

为什么不用环境变量

我试过 HOS_SDK_HOMEOHOS_SDK_HOME 等环境变量,时灵时不灵。原因是不同方式打开的终端(VS Code 内置终端 vs 系统终端 vs CI 环境)加载配置文件的顺序不一样,变量可能没被读到。flutter config 会把路径写入 Flutter 自己的配置文件,不管从哪里启动都能读到,最稳。

📋 操作

# 把鸿蒙 SDK 的位置"写死"到 Flutter 的配置里(一次性操作)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter config \
--ohos-sdk="/Applications/DevEco-Studio.app/Contents/sdk"

⚠️ Windows 用户路径改为:--ohos-sdk="C:\Program Files\Huawei\DevEco Studio\sdk"

请根据 DevEco Studio 实际安装路径调整。不确定装在哪?打开 DevEco Studio → Settings → SDK 页面可以看到路径。

终端输出 Setting "ohos-sdk" value to "..." 就成功了。

✅ 验证

不急,下一关一起验收。


🎯 第 5 关:全绿验证

目标

flutter doctor 中 HarmonyOS toolchain 一栏显示绿色对勾。

📋 操作

# 运行 Flutter 的环境诊断工具(-v 表示显示详细信息)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor -v

✅ 验证

关注输出中的 HarmonyOS 那一栏:

[✓] HarmonyOS toolchain - develop for HarmonyOS devices
    • OpenHarmony Sdk at /Applications/DevEco-Studio.app/Contents/sdk,
      available api versions has [22:default]
    • Ohpm version 6.0.1
    • Node version v18.20.1
    • Hvigorw binary at .../hvigor/bin/hvigorw

看到 [✓] 加上 4 个子项都有值 = 通关!

02_flutter_doctor.png 💡 你可能会看到 Flutter 那栏有几个 ! 警告(channel 不标准、upstream 不是官方地址)。这是鸿蒙版的正常现象,完全不影响开发和打包,放心忽略。

⚠️ 如果 HarmonyOS 那栏还是红叉,按优先级排查:

  1. SDK not found → 回第 4 关检查 config 路径是否正确
  2. ohpm/hvigorw missing → 回第一篇检查环境变量
  3. Version mismatch → 回第 3 关检查两张"证件"

🔧 附加关:FVM 的"碎碎念"

通关后你会发现,每次用 fvm flutter xxx 时 FVM 都会弹 "not a valid version" 的警告让你确认。这不是报错,只是 FVM 在说:"这个版本号我在官方列表里查不到,你确定要用吗?"

三种应对方式:

  1. 手动按 y——每次弹出输入 y 回车
  2. 自动确认——命令前加 yes |
yes | fvm flutter doctor
  1. 绕过 FVM——直接用绝对路径调用,完全不弹警告:
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor

我推荐第三种,路径虽长但最省心。可以设个快捷方式(alias)缩短它:

# 把这行加到 ~/.zshrc 里(alias = 给一条长命令起个短名字)
alias hflutter="$HOME/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter"

保存后 source ~/.zshrc,之后直接 hflutter runhflutter doctor 就行。


🏆 通关总结

项目 状态
FVM ✅ 已安装
鸿蒙版 Flutter SDK ✅ ~/fvm/versions/3.35.8-ohos-0.0.2
version 文件 ✅ 已修复
flutter.version.json ✅ 已修复
flutter config --ohos-sdk ✅ 已配置
flutter doctor HarmonyOS ✅ 全绿

回顾核心逻辑:FVM 只管官方 Flutter,鸿蒙版要我们手动塞进去(第 2 关);塞进去后"证件"信息对不上,需要手动修正(第 3 关);最后告诉 Flutter 鸿蒙 SDK 在哪(第 4 关)。理解了这条线,以后鸿蒙版 SDK 升级换版本号,你也能照样搞定。

如果中途卡住,大概率是版本号写错了——检查文件夹名、version 文件内容、flutter.version.json 里的版本号,三者必须完全一致


下一篇预告:SDK 准备好了,接下来要把你的老 Flutter 项目跑到鸿蒙上——听起来就是敲几行命令的事?没那么简单。→ 【Flutter×鸿蒙】通关手册(三):debug 包也要签名,这点和 Android 差远了

❌