HarmonyOS NEXT 初级

快捷键:

  • 两次Shift启动快速查找
  • Option+Command+L: 格式化
  • Option+Shift+Enter: 自动修复、自动补全
    • //@formatter:off/on语句对可以声明不要格式化
  • Command+7:打开代码结构树
  • Control+H: 查看接口/类的层次结构
  • Option+F7: 后点击不同图标可查看变量赋值位置和引用位置
  • 输入/**+回车键,快速生成注释信息。
    • C++文件支持用“//!+回车键生成注释信息
  • Control+Option+O可以整理imports(比如清除和排序) -> Optimize Imports
    • Settings > Editor > Code Style > Imports设置优化时是否需合并来自同一模块的import
  • Ctrl+O: Override Methods
  • Command+N:
    • Constructor: 生成构造器
    • Getter and Setter: 生成读写方法
    • Declarations: 按住Ctrl+选中项。即可在模块的Index.ets文件中批量生成相应的声明信息。

Random data

import { util } from '@kit.ArkTS';
let key: string = util.generateRandomUUID(true);
let key2: string = Math.random().toString(36).substr(2, 9);

依赖机制

node.js一样,用package.json管理(具体是oh-package.json5):

{
  "dependencies": {
    "my-utils": "file:../my_utils"   // 指向同工程的 HAR 模块
    "@ohos/lodash": "^1.0.0" // 远程ohpm包
  }
}
  • 对于普通模块,路径即依赖,按路径import即能发现
  • 对于har包, hsp包,需要先声明依赖,引用的时候,直接用包名引用,而非路径

Context

文件路径

基类Context提供了获取应用文件路径的能力,ApplicationContextAbilityStageContextUIAbilityContextExtensionContext均继承该能力。

  • ApplicationContext可以获取应用级的文件路径。该路径用于存放应用全局信息,路径下的文件会跟随应用的卸载而删除。
  • UIAbilityContext:可以获取UIAbility所在Module的文件路径。
  • ExtensionContext:可以获取ExtensionAbility所在Module的文件路径。
  • AbilityStageContext:由于AbilityStageContext创建时机早于UIAbilityContext和ExtensionContext,通常用于在AbilityStage中获取文件路径。

路由

import { BusinessError } from '@kit.BasicServicesKit';

let uiContext: UIContext = this.getUIContext(); 
// const uiContext: UIContext | undefined = AppStorage.get('uiContext');
// 上面这个叫联合类型
let router = uiContext.getRouter();
    router.pushUrl({ url: 'pages/Second' })
    .then(() => {
        console.info('Succeeded in jumping to the second page.')
    }).catch((err: BusinessError) => {
        console.error(`Failed to jump to the second page. Code is ${err.code}, message is ${err.message}`)
    })

toast

const uiContext: UIContext | undefined = AppStorage.get('uiContext');
uiContext!.getPromptAction().showToast({
    message: content,
    duration: CommonConstants.TOAST_DURATION
});

mediaquery

private smListener?: mediaquery.MediaQueryListener;
this.smListener = uiContext!.getMediaQuery().matchMediaSync(CommonConstants.WIDTH_CONDITION_SM);
this.smListener.on('change', this.isDeviceSizeSM);

this.smListener?.off('change', this.isDeviceSizeSM);

  private isDeviceSizeSM = (mediaQueryResult: mediaquery.MediaQueryResult) => {
    if (mediaQueryResult.matches) {
      this.updateCurrentDeviceSize(CommonConstants.SM);
    }
  }

    private updateCurrentDeviceSize(deviceSize: string): void {
    if (this.currentDeviceSize !== deviceSize) {
      this.currentDeviceSize = deviceSize;
      AppStorage.set<string>('currentDeviceSize', this.currentDeviceSize);
    }
  }

文件路径

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

let context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
let filename = `${context.filesDir}/fileName.txt`;

图标库(SymbolGlyph)

import { SymbolGlyphModifier } from '@kit.ArkUI';
let symbolModifier1: SymbolGlyphModifier = new SymbolGlyphModifier($r('sys.symbol.ohos_wifi'));

示例2

SymbolGlyph('sys.symbol.person_crop_circle_fill_1')
  .renderingStrategy(SymbolRenderingStrategy.MULTIPLE_OPACITY)
  .symbolEffect(new BounceSymbolEffect(EffectScope.WHOLE, EffectDirection.UP),
    this.currentIndex === index ? true : false)
  .fontSize('24fp')
  .fontColor(this.currentIndex === index ? ['#0a59f7'] : ['#66000000'])

ForEach

foreach的key尽量用id(即尽量实现keyGenerator),避免使用index

  • 因为如果你往数据源插了数据,插入位置的索引是曾经存在过的,系统会认为key没有变,就不会为这个索引重新渲染组件,最后新增的索引,读到的数据其实仍然是老数组的最后一个数据。(1,2,3)你插入一个4,期望是(1,4,2,3),其实得到的是(1,2,3,3),因为只有最后一个索引变了

如果不指定 keyGenerator,默认是index_JSON.stringify(item),这样会导致性能问题

  • 如果你插入了一个元素,后面的元素其实没变,但因为带着索引,那么后续的key全部变了,导致全部生成后面的所有组件

尽量使用箭头函数

箭头函数内可以使用this

在组件里写方法

// 1. 不需要加`function`
  private myfunc() {
    console.log('111');
  }

// 2. builder
  @Builder
  myfunc2() {
    Text('aaa');
}

// 3. 写到组件外(全局),这个时候要加`function`
@Builder
function myfunc3() {
  Button("aaa")
}

Export

export * from xxx这是中转,但这个文件本身仍然没有把xxx给import进来,所以如果自己也要用,也需要import

Resource目录

有固定结构

src/main/resources/
├── base
│   ├── element/...
│   │   ├── color.json
│   │   ├── float.json
│   │   └── string.json
│   ├── media/...
│   └── profile/...
├── en_US/...
├── zh_CN/...
└── rawfile
    ├── icon.png  <--- 文件在根目录
    ├── data
    │   └── city_list.txt  <--- 文件在子目录
    └── configs
        └── app_settings.json
  • $r('app.media.myicon')中, app指向的是resource目录,与之相对的是 sys,它用于引用系统预置的资源
  • media特指media目录,但element下的color, float, string则直接用app.color.xxxx引用,xxxx指的是对应的json文件里的键
  • rawfile文件夹下的,以其为相对路径起点使用$rawfile('icon.png'), $rawfile('data/city_list.txt')引用

能用app.color.xxx, app.string.xxx,不是文件名的原因,而是json文件里的键名,存了colorstring这样的键名。所以说上面的结构只是人为创建的,本质上除了media是在特定目录里找文件,其它都是在所有json文件里读键值对,我们人为分为数字,颜色,字符串等文件而已。

json文件格式

// zh_CN/stringarray.json
{
  "strarray": [
    {
      "name": "available_things",
      "value": [
        {
          "value": "读书"
        },
        {
          "value": "运动"
        },
        {
          "value": "旅游"
        },
        {
          "value": "听音乐"
        },
        {
          "value": "看电影"
        },
        {
          "value": "唱歌"
        }
      ]
    }
  ]
}

假定我要用资源文件存一个字符串数组,就得如上格式,键名是strarray,值是一个对象数组,数组里每个元素有一个value键,值是字符串。而不是能["读书", "运动", "旅游", "听音乐", "看电影", "唱歌"]这样的字符串数组(会报错:期望一个对象,实际得到一个字符串)

取值:

const s: string[]|undefined = this.getUIContext().getHostContext()?.resourceManager.getStringArrayValueSync($r('app.strarray.available_things').id);

先取UI上下文,再取Ability上下文,然后再取到资源管理器,然后取格式化的值。这就有类似苹果系统取plist,如果是取纯文件读字符串,这个getStringArrayValue系列的方法就不适用了,自己读成二进制再解析吧(getRawFileContent)。

import { buffer } from '@kit.ArkTS'; // 用于转换数据
import { common } from '@kit.AbilityKit';

// 在您的组件方法或事件回调中(例如按钮的onClick事件里):
Button('读取RawFile内容')
  .onClick(() => {
    // 1. 获取Ability上下文,这是关键且不可省略的一步
    const abilityContext: common.UIAbilityContext | undefined = this.getUIContext().getHostContext();

    if (abilityContext) {
      const fileName: string = '你的文件.txt'; // 请替换为rawfile目录下的实际文件名

      // 2. 使用同步方式读取文件内容(适合小文件)
      try {
        const fileData: Uint8Array = abilityContext.resourceManager.getRawFileContentSync(fileName);
        // 3. 将二进制数据(Uint8Array)转换为字符串
        const fileContent: string = buffer.from(fileData.buffer).toString();
        console.info('文件内容:', fileContent);
        // 接下来你可以使用 fileContent 了

        // 也可以用decoder:
        const textDecoder = new util.TextDecoder();
        const string = textDecoder.decodeToString(fileData);
      } catch (error) {
        console.error(`读取文件失败: ${JSON.stringify(error)}`);
      }
    } else {
      console.error('无法获取Ability上下文');
    }
  })

图片

图片可以设置解码尺寸(不同于布局尺寸)

Image($r('app.media.example'))
.sourceSize({
  width: 40,
  height: 40
})

图片默认异步加载,需要手动设置同步加载

Image($r('app.media.icon'))
  .syncLoad(true)

加载成功读取尺寸

Image($r('app.media.ic_img_2'))
.width(200)
.height(150)
.onComplete(msg => {
  if(msg){
    this.widthValue = msg.width;
    this.heightValue = msg.height;
    this.componentWidth = msg.componentWidth;
    this.componentHeight = msg.componentHeight;
  }
})

Text

span

span是子组件,但会覆盖构造器里的text

Text('我是Text') {
  Span('我是Span')
}

使用场景:

Text() {
  Span('我是Span1,').fontSize(16).fontColor(Color.Grey)
    .decoration({ type: TextDecorationType.LineThrough, color: Color.Red })
  Span('我是Span2').fontColor(Color.Blue).fontSize(16)
    .fontStyle(FontStyle.Italic)
    .decoration({ type: TextDecorationType.Underline, color: Color.Black })
  Span(',我是Span3').fontSize(16).fontColor(Color.Grey)
    .decoration({ type: TextDecorationType.Overline, color: Color.Green })
}
.borderWidth(1)
.padding(10)

可见主要是用来给文字分组控制

span不支持设置尺寸

跑马灯

超长文字原生支持跑马灯,并能传入option

.textOverflow({ overflow: TextOverflow.MARQUEE })
  .marqueeOptions({
    start: true,
    fromStart: true,
    step: 6,
    loop: -1,
    delay: 0,
    fadeout: false,
    marqueeStartPolicy: MarqueeStartPolicy.DEFAULT
  }) 

自适应大小

Text('我的最大字号为30,最小字号为5,宽度为250,maxLines为1')
  .width(250)
  .maxLines(1)
  .maxFontSize(30)
  .minFontSize(5)

原生支持复制粘贴

.copyOption(CopyOptions.InApp)

原生支持数字翻牌效果

从api 20开始

Text(this.number + "")
  .borderWidth(1)
  .fontSize(40)
  .contentTransition(this.numberTransition)

其它

  • .lineSpacing(LengthMetrics.px(20), { onlyBetweenLines: true }), 配置了行间距对首行和尾行无效
  • enableAutoSpacing(true)中英文自动加空格
  • 渐变色:
  @State linearGradientOptions: LinearGradientOptions =
    {
      direction: GradientDirection.LeftTop,
      colors: [[Color.Red, 0.0], [Color.Blue, 0.3], [Color.Green, 0.5]],
      repeating: true,
    };

  Text('aaa').shaderStyle(this.linearGradientOptions)
  • 垂直居中.textVerticalAlign(TextVerticalAlign.CENTER)
  • 选中后的菜单.copyOption(CopyOptions.InApp)
  • 自适应行数.heightAdaptivePolicy(TextHeightAdaptivePolicy.LAYOUT_CONSTRAINT_FIRST),这样不同的字体下能显示的最大行数不同,比手动设置最大行数要灵活

输入框

  • 包含TextInputTextArea
  • TextInput().type(InputType.Password) 输入框类型
  • TextInput().style(TextContentStyle.DEFAULT) 样式,default就是聚焦时加光标,inline则会显示一个小输入框
  • .contentType(ContentType.EMAIL_ADDRESS)通过contentType能设置自动填充类型
  • TextArea({text: '...'}).lineSpacing(LengthMetrics.px(20), { onlyBetweenLines: true }),又一个忽略首尾的行间距设定

控制选中文字后的菜单:

import { TextMenuController } from '@kit.ArkUI';
@Component
struct Index {
  aboutToAppear(): void {
    // 禁用所有系统服务菜单项
    // 只留下标准的“复制,剪切,全选”
    TextMenuController.disableSystemServiceMenuItems(true)
    // 或者只禁用特定选项,比如搜索和翻译
    TextMenuController.disableMenuItems([TextMenuItemId.SEARCH, TextMenuItemId.TRANSLATE])
  }

  aboutToDisappear(): void {
    // 页面消失时恢复系统服务菜单项
    TextMenuController.disableSystemServiceMenuItems(false)
  }
  //...
  TextInput({text: 'hello world'})
    .editMenuOptions({
    // 需要确认如果只是隐藏部分选项,是否需要在这里返回一下
    onCreateMenu: (menuItems: Array<TextMenuItem>) => {
        // menuItems不包含搜索和翻译
        return menuItems;
    },
    onMenuItemClick: (menuItem: TextMenuItem, textRange: TextRange) => {
        return false
    }
  })
}

扩展组件

一般是标准化封装一些常用的样式,不然每个控件需要写一样的属性

@Extend(TextInput)
function inputStyle() {
  .placeholderColor('#99182431')
  .height('45vp')
  .fontSize('18fp')
  .backgroundColor('#F1F3F5')
  .width('100%')
  .margin({ top: 12 })
}

// 使用
TextInput({ placeholder: '账号' })
  .type(InputType.Number)
  .maxLength(11)
  .inputStyle()
TextInput({ placeholder: '密码' })
  .type(InputType.Password)
  .maxLength(8)
  .inputStyle()

Select

Select(this.durationSelectOptions)
  .value('时长')
  .margin({ right: '25vp' })
Select([{ value: $r('app.string.ALL') },
  { value: $r('app.string.Completed') },
  { value: $r('app.string.Uncompleted') }])
  .value('状态')
  .margin({ right: '25vp' })

构造函数里传入的参数是SelectOption[],value更像是label, 即当前select显示的名称(未选中时显示), 主要是手机显示区域有限, 不像web一样一个label跟一个select, 等于把label直接显示在了未选择时的select上

lists

  • .listDirection(Axis.Horizontal) 更改默认方向

  • .alignListItem(ListItemAlign.Center)交叉向对齐

  • .justifyContent(JustifyContent.SpaceBetween)主轴对齐

  • .stackFromEnd(true),从底部往上滚动,比如直播的评论区(不改变组件排列,而是系统优化了初始锚点,默认显示列表底部)

  • .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true, effectEdge: EffectEdge.START })设置边缘是否回弹以及哪个边缘能回弹(默认两边全部回弹)

  • 使用ForEach创建ListItem,所有ListItem将会被创建。

    • 显示区域内的ListItem在首帧进行布局,
    • 预加载范围内的ListItem在空闲时完成布局。
    • 预加载范围之外的ListItem仅创建ListItem自身,ListItem其内部的子组件不会被创建。

      当List组件滑动时,进入预加载及显示区域的ListItem将会创建其内部的子组件并完成布局,而滑出预加载及显示区域的ListItem将不会被销毁。

  • 使用LazyForEach创建ListItem

    • 显示区域中的ListItem会被创建与布局。
    • 预加载范围内的ListItem在空闲时创建与布局,但是不会被挂载到组件树上。
    • 预加载范围外的ListItem则不会被创建

      当List组件滑动时,进入预加载及显示区域的ListItem将被创建与布局,创建ListItem过程中,若ListItem内部包含@Reusable标记的自定义组件,则会优先从缓存池中复用。滑出预加载及显示区域的ListItem将被销毁,其内部若含@Reusable标记的自定义组件,则会被回收并加入缓存池。

  • 使用Repeat创建ListItem

    • 使用virtualScroll
      * 显示区域内的ListItem将被创建和布局。
      * 预加载范围内的ListItem在空闲时创建和布局,并且**挂载至组件树上**。
      * 预加载范围外的ListItem则不会被创建。
      

      当List组件滑动时,进入预加载及显示区域的ListItem,将从缓存池中获取ListItem并复用及布局,若缓存池中无ListItem,则会新创建并布局。滑出预加载及显示区域的ListItem将被回收至缓存池。

    • 不使用virtualScroll
      * 所有ListItem均被创建(**包含子组件**)。显示区域内的ListItem在首帧完成布局,
      * 预加载范围内的ListItem在空闲时完成布局。
      * 预加载范围外的ListItem不会进行布局。
      

      当List组件滑动时,进入预加载及显示区域的ListItem将进行布局。滑出预加载及显示区域的ListItem不会销毁。(最不环保的一种)

自适应多列

struct EgLanes {
  @State egLanes: LengthConstrain = { minLength: 200, maxLength: 300 };
  build() {
    List() {
      // ...
    }
    .lanes(this.egLanes)
  }
}

上例中,如果List组件宽度只有300, 仍然只显示1列,因为不够显示两列,但是有400的话,就能显示成两列

reusable

// 使用@Reusable装饰器标记这个组件是可复用的
@Reusable
@Component
struct MessageItem {
  // 使用@Prop装饰器接收外部传入的消息内容,当复用时,aboutToReuse会更新这个值
  @Prop messageContent: string;

  // 当组件被复用时,框架会调用这个生命周期函数
  aboutToReuse(params: Record<string, Object>): void {
    // params就是组件构建时的入参
    this.messageContent = params['messageContent'] as string;
  }

  build() {
    Row() {
      Text(this.messageContent)
        .fontSize(16)
        .padding(10)
    }
  }
}

标题吸顶

List() {
  // 懒加载ListItemGroup,contactsGroups为多个分组联系人contacts和标题title的数据集合
  LazyForEach(contactsGroupsDataSource, (itemGroup: ContactsGroup) => {
    ListItemGroup({ header: this.itemHead(itemGroup.title) }) {
      // 懒加载ListItem
      if (itemGroup.contacts) {
        // 这里是例子包了一层IDataSource,所以它在解子数组的时候,把child用同样的接口包了一层,目的可能是为了使用IDataSource的特性,比如监听?
        LazyForEach(new ContactsGroupDataSource(itemGroup.contacts), (item: Contact) => {
          ListItem() {
            // ...
          }
        }, (item: Contact) => JSON.stringify(item))
      }
    }
  }, (itemGroup: ContactsGroup) => JSON.stringify(itemGroup))
}
.sticky(StickyStyle.Header)  
.listDirection(Axis.Vertical)
.edgeEffect(EdgeEffect.Spring)

如果用静态数组来forEach,数据更新时需要重新赋值整个数组(如 this.data = [...newData])才能触发UI刷新,性能开销大。IDataSource可调用特定方法(如 onDataAdd, onDataChange)通知框架进行最小范围的UI更新,性能更优。

  • LazyForEach要求其数据源必须实现 IDataSource接口。

字母表组件

const alphabets = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K',
  'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
@Entry
@Component
struct ContactsList {
  @State selectedIndex: number = 0;
  private listScroller: Scroller = new Scroller();

  build() {
    Stack({ alignContent: Alignment.End }) {
      List({ scroller: this.listScroller }) {}
      .onScrollIndex((firstIndex: number) => {
        // 根据列表滚动到的索引值,重新计算对应联系人索引栏的位置this.selectedIndex
      })

      // 字母表索引组件
      AlphabetIndexer({ arrayValue: alphabets, selected: 0 })
        .selected(this.selectedIndex)
        .onSelect((index: number) => {
          this.listScroller.scrollToIndex(index);
        })
    }
  }
}

可见一个通过controller, 一个通过selected,实现双向绑定

计算索引值时,ListItemGroup作为一个整体占一个索引值,不计算ListItemGroup内部ListItem的索引值。

滑动删除

@Builder itemEnd(index: number) {
  // 构建尾端滑出组件
  Button({ type: ButtonType.Circle }) {
    Image($r('app.media.ic_public_delete_filled'))
      .width(20)
      .height(20)
  }
  .onClick(() => {
    // this.messages为列表数据源,可根据实际场景构造。点击后从数据源删除指定数据项。
    this.messages.splice(index, 1);
  })
}

@Entry
@Component
struct SwipeAction {
  build() {
    List() {
      Repeat({ count: 10 }) { index =>
        ListItem() {
          Row() {
            Text(`Item ${index}`)
              .fontSize(20)
              .width('100%')
              .height('100%')
              .margin({ right: 20 })
          }
          .swipeAction({
            // 左滑第一个为end
            // 右滑第一个为start
              end: {
                builder: () => { this.itemEnd(index) },
            }
          })
        }
      }
    }
  }
}

角标

原理是装饰器模式,比如一个图片需要角标,那么就给图片包一层(Badge),也就是说本质上,你的组件由图片变成了角标,而不是给图片加上了角标。

ListItem() {
  Badge({
    count: 1,
    position: BadgePosition.RightTop,
    style: { badgeSize: 16, badgeColor: '#FA2A2D' }
  }) {
    Image($r('app.media.avatar'))
  }
}

滚轮弹窗(Picker)

  Text('+') //提供新增列表项入口,即给新增按钮添加点击事件
    .onClick(() => {
      this.getUIContext().showTextPickerDialog({
        range: this.availableThings, // ["a", "b", "c"...]
        onAccept: (value: TextPickerResult) => {
          let arr = Array.isArray(value.index) ? value.index : [value.index];
          for (let i = 0; i < arr.length; i++) {
            this.toDoData.push(new ToDo(this.availableThings[arr[i]])); // 新增列表项数据toDoData(可选事项)
          }
        },
      })
    })

自定义弹窗

// 1. 实现一个builder
@CustomDialog
export struct SliderCustomDialog {
  // 这里是演示一个步进器弹窗, 值存在preference里, 但读出来后放到appstorage里,方便双向绑定
  @StorageLink('fontSizeOffset') fontSizeOffset: number = 4;
  @State currentValue: number = 0;
  // 2. 需要传入一个CustomDialogController
  SliderCustomDialogController?: CustomDialogController;

  build() {
    ...
    xbutton
    .onClick(() => {
      // 3. 取消和确认按钮,绑定close事件
      this.SliderCustomDialogController?.close();
    })
  }
}

// 4. UI里
import { SliderCustomDialog } from './SliderCustomDialog';

@Component
export default struct Setting {
  @StorageLink('fontSizeOffset') fontSizeOffset: number = 0;
  SliderCustomDialogController: CustomDialogController = new CustomDialogController({
    builder: SliderCustomDialog({}),
    alignment: DialogAlignment.Bottom,
    customStyle: true,
    offset: { dx: 0, dy: -25 }
  });
  ...
}

以上官方示例里, SliderCustomDialog需要一个CustomDialogController, 而CustomDialogController则是在new的时候需要一个SliderCustomDialog, 写法上是循环依赖了, 虽然ArkTS用依赖注入机制解决了这个问题, new的时候传的builderSliderCustomDialog并不是个实例(在open的时候才会实例化),所以时机上错开了.但仍然让人困惑,更好的实践是传入确认和取消的回调函数,也就是说,这个弹窗其实只需要负责UI就行了,不需要弹绑定弹窗controller

右箭头和展开动画

@State expandedItems: boolean[] = Array(this.routes.length).fill(false);
@State selection: string | null = null;
build() {
  Column() {
    // ...

    List({ space: 10 }) {
      ForEach(this.routes, (itemGroup: ItemGroupInfo) => {
        ListItemGroup({
          header: this.ListItemGroupHeader(itemGroup),
          style: ListItemGroupStyle.CARD,
        }) {
          if (this.expandedItems[itemGroup.index] && itemGroup.children) {
            ForEach(itemGroup.children, (item: ItemInfo) => {
              ListItem({ style: ListItemStyle.CARD }) {
                Row() {
                  Text(item.name)
                  Blank()
                  if (item.type === 'Image') {
                    Image(item.label)
                      .height(20)
                      .width(20)
                  } else {
                    Text(item.label)
                  }
                  Image($r('sys.media.ohos_ic_public_arrow_right'))
                    .fillColor($r('sys.color.ohos_id_color_fourth'))
                    .height(30)
                    .width(30)
                }
                .width("100%")
              }
              .width("100%")
              .animation({ curve: curves.interpolatingSpring(0, 1, 528, 39) })
            })
          }
        }.clip(true)
      })
    }
    .width("100%")
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Start)
  .backgroundColor($r('sys.color.ohos_id_color_sub_background'))
}

@Builder
ListItemGroupHeader(itemGroup: ItemGroupInfo) {
  Row() {
    Text(itemGroup.label)
    Blank()
    Image($r('sys.media.ohos_ic_public_arrow_down'))
      .fillColor($r('sys.color.ohos_id_color_fourth'))
      .height(30)
      .width(30)
      .rotate({ angle: !!itemGroup.children.length ? (this.expandedItems[itemGroup.index] ? 0 : 180) : 180 })
      .animation({ curve: curves.interpolatingSpring(0, 1, 228, 22) })
  }
  .width("100%")
  .padding(10)
  .animation({ curve: curves.interpolatingSpring(0, 1, 528, 39) })
  .onClick(() => {
    if (itemGroup.children.length) {
      this.getUIContext()?.animateTo({ curve: curves.interpolatingSpring(0, 1, 528, 39) }, () => {
        this.expandedItems[itemGroup.index] = !this.expandedItems[itemGroup.index];
      })
    }
  })
}
  • 右箭头的图标和颜色用的sys开头的资源
  • ListItemGroup的clip
  • animation的interpolating方式(mass, stiffness, damping, initialVelocity),即质量,刚性,阻尼和初速度。刚性和阻尼越大,震幅越小震动过程越短

添加删除的动画

演示todolist从未完成到已完成两个区域间的切换

addAchieveData() {
  this.toDoItem.isCompleted = !this.toDoItem.isCompleted;
  this.UIContext.animateTo({ duration: STYLE_CONFIG.ANIMATION_DURATION }, () => {
    if (this.toDoItem.isCompleted) {
      let tempData = this.toDoData.filter(item => item.key !== this.toDoItem.key);
      this.toDoData = tempData;
      this.achieveData.push(this.toDoItem);
    } else {
      let tempData = this.achieveData.filter(item => item.key !== this.toDoItem.key);
      this.achieveData = tempData;
      this.toDoData.push(this.toDoItem);
    }
  });
}

嵌套滚动

用来模拟列表向下滚动时,有一个横向的tab列表(也可以滚动)会根据列表滚动的位置高亮当前的tab项

v-scroll
 card(未分类的内容)
 h-scroll(用来作分类标签)
 list(v-scroll)(内容列表)
  cards
List({ scroller: this.scroller }) {
  ForEach(this.classifyList, (item: ClassifyModel, index: number) => {
    ListItem() {
      CardItemComponent({ classifyModel: item })
    }
  }, (item: ClassifyModel, _index: number) => JSON.stringify(item.classifyName))
}
.nestedScroll({
  scrollForward: NestedScrollMode.PARENT_FIRST,
  scrollBackward: NestedScrollMode.SELF_FIRST
})
// 通知当前分类变化s
.onScrollIndex((start: number) => this.classifyChangeAction(start, false))

// 确定当前分类的方法
classifyChangeAction(startIndex: number, isClassify: boolean) {
  if (this.currentClassify !== startIndex) {
    this.currentClassify = startIndex;
    if (isClassify) {
      this.scroller.scrollToIndex(startIndex);
    } else {
      this.classifyScroller.scrollToIndex(startIndex);
    }
  }
}

上面顺便演示了两个列表滚动位置的互相通知,真正要演示的是嵌套滚动的优先级:PARENT_FIRST表示父列表优先滚动,SELF_FIRST表示子列表优先滚动

Flex

ColumnRow封装了一些Flex的布局属性成为了属性方法,还重命名了,可以用回Flex使用回原生的属性,特别如:Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center })这两个轴向和交叉向的属性

  • flex默认水平,不折行

Swiper

private swiperController: SwiperController = new SwiperController();
Swiper(this.swiperController) {
  // ...
}
.loop(true)
.autoPlay(true)
.interval(1000)
.vertical(false) // 轮播方向
.displayCount(2) // 每页显示个数
.displayArrow(true, false) // 显示箭头
// 个性化箭头
.displayArrow({
  showBackground: true,
  isSidebarMiddle: true,
  backgroundSize: 24,
  backgroundColor: Color.White,
  arrowSize: 18,
  arrowColor: Color.Blue
}, false)
.indicator(true) // 导航条
.indicator( // 个性化
  Indicator.dot() // 或:
  // new DotIndicator()
  // new DigitIndicator()
    .left(0)
    .itemWidth(15)
    .itemHeight(15)
    .selectedItemWidth(30)
    .selectedItemHeight(15)
    .color(Color.Red)
    .selectedColor(Color.Blue)
    .space(LengthMetrics.vp(3)) // 导航点间距
    .bottom(LengthMetrics.vp(0), true) // 底部贴边(bottom为零+忽略本身大小)
)
.curve(curves.interpolatingSpring(-1, 1, 328, 34)) // 动画曲线
// 事件
// 通常被controller触发,强制显示到哪一页
.onSelected((index: number) => {
  console.info("onSelected:" + index);
  this.currentIndex = index;
  this.tabsController.changeIndex(index);
})
// 用户可滑动触发,也可被controller触发
.onChange((index) => {
  //
})

用controller控制:

  • this.swiperController.showNext(); // showPrevious
  • this.swiperController.changeIndex(this.targetIndex, this.animationMode);
  • mode: SwiperAnimationMode.NO_ANIMATION, DEFAULT_ANIMATION, FAST_ANIMATION

自定义导航动画,主要从proxy取数据:

Swiper() {
  // ...
}
.customContentTransition({
  timeout: 1000,
  transition: (proxy: SwiperContentTransitionProxy) => {
    if (proxy.position <= proxy.index % this.DISPLAY_COUNT || proxy.position >= this.DISPLAY_COUNT + proxy.index % this.DISPLAY_COUNT) {
      // 同组页面完全滑出视窗外时,重置属性值
      this.opacityList[proxy.index] = 1.0;
      this.scaleList[proxy.index] = 1.0;
      this.translateList[proxy.index] = 0.0;
      this.zIndexList[proxy.index] = 0;
    } else {
      // 同组页面未滑出视窗外时,对同组中左右两个页面,逐帧根据position修改属性值
      if (proxy.index % this.DISPLAY_COUNT === 0) {
        this.opacityList[proxy.index] = 1 - proxy.position / this.DISPLAY_COUNT;
        this.scaleList[proxy.index] = this.MIN_SCALE + (1 - this.MIN_SCALE) * (1 - proxy.position / this.DISPLAY_COUNT);
        this.translateList[proxy.index] = - proxy.position * proxy.mainAxisLength + (1 - this.scaleList[proxy.index]) * proxy.mainAxisLength / 2.0;
      } else {
        this.opacityList[proxy.index] = 1 - (proxy.position - 1) / this.DISPLAY_COUNT;
        this.scaleList[proxy.index] = this.MIN_SCALE + (1 - this.MIN_SCALE) * (1 - (proxy.position - 1) / this.DISPLAY_COUNT);
        this.translateList[proxy.index] = - (proxy.position - 1) * proxy.mainAxisLength - (1 - this.scaleList[proxy.index]) * proxy.mainAxisLength / 2.0;
      }
      this.zIndexList[proxy.index] = -1;
    }
  }
})

产生如下效果:

网格布局Grid

Grid() {
  // ...
}
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 2fr 1fr')

创建了一个三行三列的网格,其中第二列占50%宽

如果要实现跨行或跨列,用GridLayoutOptions

layoutOptions: GridLayoutOptions = {
  regularSize: [1, 1],
  onGetRectByIndex: (index: number) => {
    if (index == key1) { // key1是“0”按键对应的index
      return [6, 0, 1, 2];
    } else if (index == key2) { // key2是“=”按键对应的index
      return [5, 3, 2, 1];
    }
    // ...
    // 这里需要根据具体布局返回其他item的位置
  }
}

Grid(undefined, this.layoutOptions) {
  // ...
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')

这里,0=按键跨了两个单元格,

  • 0按键在(6,0)位置,大小是(1行,2列),
  • =按钮在(5,3)位置,大小是(2行,1列)
  • 所以,通过onGetRectByIndex返回的值就是(行,列,行数,列数)

其它属性:

Grid() {
  // ...
}
.maxCount(3)
.layoutDirection(GridDirection.Row) // Row, Column
.columnsGap(10)
.rowsGap(15)

// 只设一个方向,比如下面的例子,那就固定了行数,列数无限延展
.rowsTemplate('1fr 1fr') 
.rowsGap(15)

// 代码控制滚动:
Column({ space: 5 }) {
  Grid(this.scroller) {
  }
  .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')

  Row({ space: 20 }) {
    Button('上一页')
      .onClick(() => {
        this.scroller.scrollPage({
          next: false
        });
      })

    Button('下一页')
      .onClick(() => {
        this.scroller.scrollPage({
          next: true
        });
      })
  }
}

Tabs

Tabs({ barPosition: BarPosition.End }) {
  TabContent() {
    Text('首页的内容').fontSize(30)
  }
  // 生命周期事件
  .onWillShow(() => {
    console.info('tab 1 show');
  })
  .onWillHide(() => {
    console.info('tab 1 hide');
  })
  .tabBar('首页')

  TabContent() {
    Text('推荐的内容').fontSize(30)
  }
  // 自定义样式
  .tabBar(new BottomTabBarStyle({
            normal: this.symbolModifier4,
          },'推荐'))

  TabContent() {
    Text('发现的内容').fontSize(30)
  }
  .tabBar('发现')
  
  TabContent() {
    Text('我的内容').fontSize(30)
  }
  .tabBar("我的")
}
.vertical(false)
.scrollable(true)
.barMode(BarMode.Fixed) // 固定导航栏,见一般APP的tab首页
.barMode(BarMode.Scrollable) // 新闻类APP的tab分类,可以滑动
// 主动切换事件
.onChange((index:number)=>{
  console.info(index.toString());
})
// 被动切换事件
.onSelected((index: number) => {
  this.selectIndex = index;
})
  • 底部导航栏可通过设置TabContent的BottomTabBarStyle来实现底部页签样式

see more

侧边导航

Tabs({ barPosition: BarPosition.Start }) {
  // TabContent的内容:首页、发现、推荐、我的
  // ...
}
.vertical(true)
.barWidth(100)
.barHeight(200)

Tab套Tab,需要限制其中一个的滚动切换:

Tabs({ barPosition: BarPosition.End }) {
  TabContent(){
    Column(){
      Tabs(){
        // 顶部导航栏内容
        // ...
      }
    }
    .backgroundColor('#ff08a8f1')
    .width('100%')
  }
  .tabBar('首页')

  // 其他TabContent内容:发现、推荐、我的
  // ...
}
.scrollable(false) // 最外层不能滑动切换Tab

系统默认情况下采用了下划线标识当前活跃的页签,而自定义导航栏需要自行实现相应的样式,用于区分当前活跃页签和未活跃页签。

@State currentIndex: number = 0;

// 把状态与样式绑定,动态更改图标和颜色
@Builder tabBuilder(title: string, targetIndex: number, selectedImg: Resource, normalImg: Resource) {
  Column() {
    Image(this.currentIndex === targetIndex ? selectedImg : normalImg)
      .size({ width: 25, height: 25 })
    Text(title)
      .fontColor(this.currentIndex === targetIndex ? '#1698CE' : '#6B6B6B')
  }
  .width('100%')
  .height(50)
  .justifyContent(FlexAlign.Center)
}

// 使用
TabContent() {
  Column(){
    Text('我的内容')  
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#007DFF')
}
.tabBar(this.tabBuilder('我的', 0, $r('app.media.mine_selected'), $r('app.media.mine_normal')))

编程切换index:

Button('changeIndex').width('50%').margin({ top: 20 })
  .onClick(()=>{
    let index = (this.currentIndex + 1) % 4;
    this.controller.changeIndex(index);
})
  • 开发者可以通过Tabs组件的onContentWillChange接口,设置自定义拦截回调函数。拦截回调函数在下一个页面即将展示时被调用,如果回调返回true,新页面可以展示;如果回调返回false,新页面不会展示,仍显示原来页面
  • 默认情况下Tabs创建时会一次性预加载所有TabContent,而且已加载的页面不会释放,可能会带来性能内存问题。
    • 此时可以设置cachedMaxCount属性控制缓存的页面数量,设置此属性后不会进行页面预加载,使用懒加载机制
Navigation() {
  // ...
}
.mode(NavigationMode.Auto) // auto就是stack,即页面栈,否则就是split分屏

自带有menutoolbar,都是用一个对象定义:

@Entry
@Component
struct NavigationExample {
  @State toolTmp: ToolbarItem = {
    'value': "func",
    'icon': "./image/ic_public_highlights.svg",  // 当前目录image文件夹下的图标资源
    'action': () => {}
  };
  // api 10以后都用navPathStack
  @Provide('navPathStack') navPathStack: NavPathStack = new NavPathStack();

  @Builder
  pageMap(name: string) {
    if (name === "NavDestinationTitle1") {
      pageOneTmp();
    } else if (name === "NavDestinationTitle2") {
      pageTwoTmp();
    } else if (name === "NavDestinationTitle3") {
      pageThreeTmp();
    }
  }

  build() {
    Column() {
      Navigation(this.navPathStack) {
        TextInput({ placeholder: 'search...' })
          .width("90%")
          .height(40)
          .backgroundColor('#FFFFFF')

        List({ space: 12 }) {
          ForEach(this.arr, (item: number) => {
            ListItem() {
              Text("Page" + item)
                .onClick(() => {
                  this.navPathStack.pushPath({ name: 'NavDestinationTitle' + item });
                })
            }
          }, (item: number) => item.toString())
        }
      }
      .title("主标题")
      .mode(NavigationMode.Split)
      .titleMode(NavigationTitleMode.Mini) // mini类似iOS的正常标题,Full则是大标题
      .navDestination(this.pageMap)
      // 竖屏最多3个,横屏最多5个,多余的会放到一个自动生成的"更多“图标里
      .menus([
        {
          value: "", icon: "./image/ic_public_search.svg", action: () => {
          }
        },
        {
          value: "", icon: "./image/ic_public_add.svg", action: () => {
          }
        }, ...
      ])
      .toolbarConfiguration([this.toolTmp, this.toolTmp, this.toolTmp])
    }
  }
}

子页面demo:

@Component
struct Page01 {
  pathStack: NavPathStack | undefined = undefined;
  pageParam: string = '';

  build() {
    NavDestination() {
      // ...
    }.title('Page01')
    .mode(NavDestinationMode.DIALOG) // 默认是STANDARD 即页面
    // 首次创建时有此事件
    .onReady((context: NavDestinationContext) => {
      this.pathStack = context.pathStack;
      this.pageParam = context.pathInfo.param as string;
    })
    // 任意层级都可以接onResult,只要它的子页是通过:
    // navpathstack.popResult({result: new NavParam()})弹出的
    .onResult((param: Object) => {
      // param 就是子页面 B 传来的 { result: new NavParam() } 中的 result
      if (param instanceof NavParam) {
        console.info('收到子页面返回的数据:', param.desc);
      }
    })
  }
}
  • 每个Navigation都需要创建并传入一个NavPathStack对象
  • 菜单和工具栏的图标不是用的$r机制,所以需要写物理路径,以下两种,一个在自定义目录,一个在资源目录,但访问方式是一致的,即全路径:
    • 'ets/pages/navigation/template1/image/ic_public_add.svg',
    • 'resources/base/media/ic_public_add.svg',
  • 工具栏实现出来默认样式就跟tabbar一样
  • 子页面必须是NavDestination为要有的组件
    • 返回按钮的拦截需要在 NavDestinationonBackPressed 中处理

页面跳转

  • 跳转: pushPathpushPathByNamepushDestinationpushDestinationByName
    • 替换:replacePathreplacePathByNamereplaceDestination
  • 返回:poppopToNamepopToIndexclear(还有个popResult吧)
  • 删除:removeByNameremoveByIndexesremoveByNavDestinationId,一般用在不能返回的场景,比如支付,登录,操作成功后不让返回上一页,就可以直接删除
  • 移动:moveToTopmoveIndexToTop

其它:

// 获取栈中所有页面name集合
this.pageStack.getAllPathName();
// 获取索引为1的页面参数
this.pageStack.getParamByIndex(1);
// 获取PageOne页面的参数
this.pageStack.getParamByName("PageOne");
// 获取PageOne页面的索引集合
this.pageStack.getIndexByName("PageOne");

页面跳转其实只是进行了压栈操作,你还得自己配置NavDestination进行真实页面的跳转(所以如果只是简单跳页,还不是用route模块)

import router from '@ohos.router';

// 跳转到指定页面(按页面路径)
router.pushUrl({
  url: 'pages/PageTwo'  // 对应 resources/base/profile/main_pages.json 中的路径
});

如果用pagePathStack方案也要一句话跳转,则需要配置路由表

路由表

  1. 在跳转目标模块的配置文件src/main/module.json5添加路由表配置:
  {
    "module" : {
      "routerMap": "$profile:route_map"
    }
  }
  1. 添加完路由配置文件地址后,需要在工程resources/base/profile中创建route_map.json文件。添加如下配置信息:
  {
    "routerMap": [
      {
        "name": "PageOne",
        "pageSourceFile": "src/main/ets/pages/PageOne.ets",
        "buildFunction": "PageOneBuilder",
        "data": {
          "description" : "this is PageOne"
        }
      }
    ]
  }
  1. 在真实页面(即pageSourceFile)里实现配置里的buildFunction
  // 跳转页面入口函数
  @Builder
  export function PageOneBuilder() {
    PageOne();
  }

  @Component
  struct PageOne {
    pathStack: NavPathStack = new NavPathStack();

    build() {
      NavDestination() {
      }
      .title('PageOne')
      .onReady((context: NavDestinationContext) => {
         this.pathStack = context.pathStack;
      })
    }
  }
  1. 现在就可以一句话跳转了
this.pageStack.pushPathByName("PageOne", null, false);

拦截

willShow, didShow, modeChange:

this.pageStack.setInterception({
  willShow: (from: NavDestinationContext | "navBar", to: NavDestinationContext | "navBar",
    operation: NavigationOperation, animated: boolean) => {
    if (typeof to === "string") { // 其实只可能是"navBar"
      console.info("target page is navigation home page.");
      return;
    }
    // 将跳转到PageTwo的路由重定向到PageOne
    let target: NavDestinationContext = to as NavDestinationContext;
    if (target.pathInfo.name === 'PageTwo') {
      target.pathStack.pop();
      target.pathStack.pushPathByName('PageOne', null);
    }
  }
})

转场

// 全局关闭
aboutToAppear(): void {
  this.pageStack.disableAnimation(true);
}
// 单次关闭
this.pageStack.pushPath({ name: "PageOne" }, false);
this.pageStack.pop(false);

模态转场

打开模态窗口也算一个转场,用bindSheet

@State isShowSheet: boolean = false;

@Builder
mySheet() {
  Image($r('app.media.bg_transition'))
}

...

// 弹窗UI
@Builder
mySheet() {
  Image($r('app.media.bg_transition'))
}

// 页面UI
build() {
  NavDestination() {
    Column() {
      // 触发状态变更即可
      Image($r('app.media.bg_transition')).onClick(() => {
        this.isShowSheet = true;
      })
    }
    // 注册到页面根组件即可,关联状态和被弹窗组件
    .bindSheet($$this.isShowSheet, this.mySheet(), {
      height: SheetSize.MEDIUM
    })
  }
}

从ArkUI组件树层级上来看,Overlay浮层、弹窗、模态、带Order的Overlay浮层都挂载在Root节点下。

自定义转场,共享元素转场

后退

  • 后退(点按钮,手机后退按钮,侧滑返回)会触发`onBackPressed``,可以在这里处理返回逻辑,比如拦截返回,或者返回到指定页面
  • 编辑触发后退:
    • 原生router:router.back({url: 'pages/home}) 不传参数就是纯后退
    • 在 NavDestination中调this.navPageInfos.pop();
  • 隐藏返回按钮:
    • 在 NavDestination中隐藏 NavDestination() {}.hideBackButton(true)
    • 或配置:
@Component
struct MyPage {
  aboutToAppear() {
    // 获取页面上下文
    let context = getContext(this) as common.UIAbilityContext;
    let windowClass = context.getWindow();
    
    // 隐藏导航栏
    windowClass.setWindowSystemBarEnable(['status']);  // 只显示状态栏
    // 或完全隐藏
    // windowClass.setWindowSystemBarEnable([]);
  }
}

串一下


// 在A页面跳转子页面:
.onClick(() => {
  // 可见传了两个参数
  this.navPathStack.pushPathByName(`PageOne`, '详情页面参数'); 
})

// PageOne的构造

// 对应src/main/resources/base/profile/router_map.json里的配置
// 即有哪些页面(pageSourceFile),构造方法是什么(buildFunction)
// 所以:
// pageSourceFile: PageOne.ets
// buildFunction: PageOneBuilder
@Builder
export function PageOneBuilder(name: string, param: string) {
  // 这里定义了两个参数,一个name,一个param,param类型也是自定义的
  // 注意,这里的name只是页面需要,并不是路由系统里注册的name
  // 路由系统的name由route_map.json的routeMap.name定义
  PageOne({ name: name, value: param });
}

@Component
export struct PageOne {
  navPathStack: NavPathStack = new NavPathStack();
  // 两个属性,即使是@State直接也直接标就行,不要理解为是传入的
  name: string = '';
  @State value: string = '';

  build() {
    NavDestination() {
      // my view
    }
  }
}
// 整个大前提是在模块的src/main/module.json5里配置好:
// "routerMap": "$profile:router_map",

UIAbility

UIAbility是系统调度的最小单元。在设备内的功能模块之间跳转时,会涉及到启动特定的UIAbility,包括应用内的其他UIAbility、或者其他应用的UIAbility(例如启动三方支付UIAbility)。

  • 任务视图中看到一个任务,使用“一个UIAbility+多个页面”的方式,可以避免不必要的资源加载。
  • 在任务视图中看到多个任务,或者需要同时开启多个窗口,建议使用多个UIAbility实现不同的功能。

例如,即时通讯类应用中的消息列表与音视频通话采用不同的UIAbility进行开发,既可以方便地切换任务窗口,又可以实现应用的两个任务窗口在一个屏幕上分屏显示。

生命周期

UIAbility可以通过UIAbilityContext.startAbilityByCall()接口启动到后台,系统会依次触发onCreate()、onBackground()(不会执行onWindowStageCreate()生命周期回调)生命周期回调。当用户将UIAbility拉到前台,系统会依次触发onNewWant()、onWindowStageCreate()、onForeground()生命周期回调。

  • 在首次创建UIAbility实例时,系统触发onCreate()回调。(生命周期仅此一次)
  • 在进入前台之前,系统会创建一个WindowStage。WindowStage创建完成后会进入onWindowStageCreate()回调
    • 在onWindowStageCreate()回调中通过loadContent()方法设置应用要加载的页面 (比如应用入口的页面就是在这里设置的)
    • 并根据需要调用on('windowStageEvent')方法订阅WindowStage的事件(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)。
  • UI可见之前,系统触发onForeground回调。 >> 比如可以开启定位
  • 在UIAbility的UI完全不可见之后,系统触发onBackground回调,将UIAbility实例切换至后台状态。开发者可以在该回调中释放UI不可见时的无用资源
  • 在UIAbility实例销毁之前,系统触发onWindowStageWillDestroy()回调。该回调在WindowStage销毁前执行,此时WindowStage可以使用。开发者可以在该回调中释放通过WindowStage获取的资源、注销WindowStage事件订阅等。
    • 比如注销事件订阅this.windowStage.off('windowStageEvent');
  • WindowStage销毁后,系统触发onWindowStageDestroy()回调,开发者可以在该回调中释放UI资源。
  • 在UIAbility实例销毁之前,系统触发onDestroy回调。该回调是UIAbility接收到的最后一个生命周期回调,开发者可以在onDestroy()回调中进行系统资源的释放、数据的保存等操作。
  • 当应用的UIAbility实例已创建,再次调用方法启动该UIAbility实例时,系统触发该UIAbility的onNewWant()回调。开发者可以在该回调中更新要加载的资源和数据等,用于后续的UI展示。

订阅WindowStage事件:

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN_NUMBER: number = 0xFF00;

export default class EntryAbility extends UIAbility {
  // ...
  onWindowStageCreate(windowStage: window.WindowStage): void {
    // 设置WindowStage的事件订阅(获焦/失焦、切到前台/切到后台、前台可交互/前台不可交互)
    try {
      windowStage.on('windowStageEvent', (data) => {
        let stageEventType: window.WindowStageEventType = data;
        switch (stageEventType) {
          case window.WindowStageEventType.SHOWN: // 切到前台
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage foreground.`);
            break;
          case window.WindowStageEventType.ACTIVE: // 获焦状态
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage active.`);
            break;
          case window.WindowStageEventType.INACTIVE: // 失焦状态
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage inactive.`);
            break;
          case window.WindowStageEventType.HIDDEN: // 切到后台
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage background.`);
            break;
          case window.WindowStageEventType.RESUMED: // 前台可交互状态
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage resumed.`);
            break;
          case window.WindowStageEventType.PAUSED: // 前台不可交互状态
            hilog.info(DOMAIN_NUMBER, 'testTag', `windowStage paused.`);
            break;
          default:
            break;
        }
      });
    } catch (exception) {
      hilog.error(DOMAIN_NUMBER, 'testTag',
        `Failed to enable the listener for window stage event changes. Cause: ${JSON.stringify(exception)}`);
    }
    hilog.info(DOMAIN_NUMBER, 'testTag', `%{public}s`, `Ability onWindowStageCreate`);
    // 设置UI加载
    windowStage.loadContent('pages/Index', (err, data) => {
      // ...
    });
  }
}

启动模式

module.json5配置文件中的launchType字段可以设置UIAbility的启动模式,包括单实例模式(singleton)和多实例模式(multiton)。默认为单实例模式。还有一个指定实例模式(specified)

  • 单实例模式每次调用startAbility()方法时,如果应用进程中该类型的UIAbility实例已经存在,则复用系统中的UIAbility实例。
    • 触发onNewWant()回调
  • 多实例模式,每次调用startAbility()方法时,都会在应用进程中创建一个新的该类型UIAbility实例。即在最近任务列表中可以看到有多个该类型的UIAbility实例。
  • 指定实例模式, 根据传入的key, 判断是走onNewWant()还是onCreate().
    • 假如一个文档应用,主页面AAbility启动文档BAbility, "新建"按钮每次给一个不同的key, 那么每次都会走onCreate(), "打开"按钮每次给一个相同的key, 那么每次都会走onNewWant()
    • 系统在拉起SpecifiedAbility之前,会先进入对应的AbilityStage的onAcceptWant()生命周期回调,获取用于标识目标UIAbility的Key值。
// 新建按钮
Button()
  .onClick(() => {
    let context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
    // context为调用方UIAbility的UIAbilityContext;
    let want: Want = {
      deviceId: '', // deviceId为空表示本设备
      bundleName: 'com.samples.stagemodelabilitydevelop',
      abilityName: 'SpecifiedFirstAbility',
      moduleName: 'entry', // moduleName非必选
      // 参数, 在目标Ability的onCreate()或者onNewWant()生命周期回调文件中接收
      parameters: {
        // 自定义信息
        instanceKey: this.KEY_NEW
      }
    };
    context.startAbility(want).then(() => {
      hilog.info(DOMAIN_NUMBER, TAG, 'Succeeded in starting SpecifiedAbility.');
    }).catch((err: BusinessError) => {
      hilog.error(DOMAIN_NUMBER, TAG, `Failed to start SpecifiedAbility. Code is ${err.code}, message is ${err.message}`);
    })
    this.KEY_NEW = this.KEY_NEW + 'a'; // 保证key总是新的
  })

启动Ability

上面例子演示了一个startAbility, 如果需要接返回值:

context.startAbilityForResult(want).then((data) => {
  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)
      });
    }
  }
}

一般是在terminate里返回的:

.onClick(() => {
  let context = this.getUIContext().getHostContext() as common.UIAbilityContext; // UIAbilityContext
  const RESULT_CODE: number = 1001;
  // 构造一个result对象
  let abilityResult: common.AbilityResult = {
    resultCode: RESULT_CODE,
    want: {
      bundleName: 'com.samples.stagemodelabilitydevelop',
      moduleName: 'entry', // moduleName非必选
      abilityName: 'FuncAbilityB',
      parameters: {
        info: '来自FuncAbility Index页面'
      },
    },
  };
  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;
    }
  });
})

上下文

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

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 获取UIAbility实例的上下文
    let context = this.context;
    // ...
  }
}

通过UIAbilityContext可以获取UIAbility的相关配置信息,如包代码路径、Bundle名称、Ability名称和应用程序需要的环境状态等属性信息,以及可以获取操作UIAbility实例的方法(如startAbility()、connectServiceExtensionAbility()、terminateSelf()等)。

调用terminateSelf()方法停止当前UIAbility实例时,默认会保留该实例的快照(Snapshot),即在最近任务列表中仍然能查看到该实例对应的任务。如不需要保留该实例的快照,可以在其对应UIAbility的module.json5配置文件中,将abilities标签的removeMissionAfterTerminate字段配置为true。

在实例中获取:

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

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

  startAbilityTest(): void {
    let want: Want = {
      // Want参数信息
    };
    this.context.startAbility(want);
  }

  // 页面展示
  build() {
    // ...
  }
}

获取拉取方信息:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 调用方无需手动传递parameters参数,系统会自动向Want对象中传递调用方信息。
  console.info(`onCreate, callerPid: ${want.parameters?.['ohos.aafwk.param.callerPid']}.`);
  console.info(`onCreate, callerBundleName: ${want.parameters?.['ohos.aafwk.param.callerBundleName']}.`);
  console.info(`onCreate, callerAbilityName: ${want.parameters?.['ohos.aafwk.param.callerAbilityName']}.`);
}

UIAbility组件与UI的数据同步

使用EventHub进行数据通信

// onCreate的时候订阅
let eventhub = this.context.eventHub;
// 执行订阅操作
eventhub.on('event1', this.eventFunc);
eventhub.on('event1', (data: string) => {
  // 触发事件,完成相应的业务操作
});

// UI中emit
private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

this.context.eventHub.emit('event1', 2, 'test');
this.context.eventHub.off('event1');

上例中eventHub的流向也可以反过来,比如要记录生命周期, 可以在UIAbility中emit, 在UI中订阅(.on)

使用AppStorage/LocalStorage进行数据同步:

  • AppStorage是一个全局的状态管理器,适用于多个UIAbility共享同一状态数据的情况;
  • LocalStorage则是一个局部的状态管理器,适用于单个UIAbility内部使用的状态数据。

启动指定页面

调用方UIAbility指定被调用方启动页面, 在want里配置

let want =  {...
  parameters: { // 自定义参数传递页面信息
    router: 'funcA'
  }
}

接收:

  1. 目标是冷启动 在目标Ability的onCreate回调中获取Want`并保存 onWindowStageCreate中从want中取出参数, 确定启动页面,再windowStage.loadContent(url, (err, data) => {}`
  2. 热启动
  • onNewWant()中解析参数进行处理。

从示例代码来看,热启动只是存了路径,并没有跳转的机制, 而是在启动到Index页面的时候取出保存的路径自行跳转(这就有个问题, 热启动拉起的页面未必是在Index吧?),比如进行路由跳转或添加到导航栈

示例地址

网络

  • 声明接口调用所需要的权限:ohos.permission.INTERNET, ohos.permission.GET_NETWORK_INFO
  • import { connection } from '@kit.NetworkKit';
  • 获取所有注册网络connection.getAllNets().then((data: connection.NetHandle[]) => {}
  • 解析域名connection.getAddressesByName("xxxx").then((data: connection.NetAddress[]) => {}
  • 获取默认网络connection.getDefaultNet().then((data:connection.NetHandle) => {}
    • connection.getNetCapabilities(data).then((data: connection.NetCapabilities) => {}
      • data.bearerTypes 网络类型, 如蜂窝,wifi等
      • data.networkCap 网络能力,如是能访问网络,VPN等
        • connection.NetCap.NET_CAPABILITY_VALIDATED即为可上网
    • connection.getConnectionProperties(netHandleInfo) 获取网络属性
// entry/src/main/module.json5
"requestPermissions": [
  {
    "name": "ohos.permission.INTERNET",
    "reason": "$string:Internet",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "inuse"
    }
  },
  {
    "name": "ohos.permission.GET_NETWORK_INFO",
    "reason": "$string:network_info",
    "usedScene": {
      "abilities": [
        "EntryAbility"
      ],
      "when": "inuse"
    }
  }
]
let netSpecifier: connection.NetSpecifier = {
  netCapabilities: {
    // 假设当前默认网络是WiFi,需要创建蜂窝网络连接,可指定网络类型为蜂窝网。
    bearerTypes: [connection.NetBearType.BEARER_CELLULAR],
    // 指定网络能力为Internet。
    networkCap: [connection.NetCap.NET_CAPABILITY_INTERNET]
  },
};

// 指定超时时间为10s(默认值为0)。
let timeout = 10 * 1000;

// 创建NetConnection对象。
let conn = connection.createNetConnection(netSpecifier, timeout);

// 订阅事件,如果当前指定网络可用,通过on_netAvailable通知用户。
conn.on('netAvailable', ((data: connection.NetHandle) => {
  console.info("net is available, netId is " + data.netId);
}));

// 订阅事件,如果当前指定网络不可用,通过on_netUnavailable通知用户。
conn.on('netUnavailable', ((data: void) => {
  console.info("net is unavailable, data is " + JSON.stringify(data));
}));

// 上述订阅的成立条件是要先注册?
// 订阅指定网络状态变化的通知。
conn.register((err: BusinessError, data: void) => {
  console.info(JSON.stringify(err));
});

其它用法见官网

数据请求

  • HttpRequest.request
  • HttpRequest.requestInstream -> 上传,下载,或关心进度
  • 请求头: 当请求方式为"POST" "PUT" "DELETE" 或者""时,默认{'content-Type': 'application/json'}, 否则默认{'content-Type': 'application/x-www-form-urlencoded'}。

发起普通请求

import { http } from '@kit.NetworkKit';
let httpRequest = http.createHttp();

// 头比response先返回
httpRequest.on('headersReceive', (header) => {
  console.info('header: ' + JSON.stringify(header));
});

httpRequest.request(
  "EXAMPLE_URL",
  {
        method: http.RequestMethod.POST, // 默认GET
    header: {
      'Content-Type': 'application/json'
    },
    // 当使用POST请求时此字段用于传递请求体内容,具体格式与服务端协商确定。
    extraData: "data to send",
    expectDataType: http.HttpDataType.STRING, 
    ...
  }, (err: BusinessError, data: http.HttpResponse) => {
    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+
      // 当该请求使用完毕时,调用destroy方法主动销毁。
      httpRequest.destroy();
    } else {
      console.error('error:' + JSON.stringify(err));
      // 取消订阅HTTP响应头事件。
      httpRequest.off('headersReceive');
      // 当该请求使用完毕时,调用destroy方法主动销毁。
      httpRequest.destroy();
    }
  }

);

发起流式请求

HTTP流式传输是指在处理HTTP响应时,可以一次只处理响应内容的一小部分,而不是一次性将整个响应加载到内存,这对于处理大文件、实时数据流等场景非常有用。

正常发起请求, 异步接收数据


httpRequest.requestInStream("EXAMPLE_URL", {options}).then((data: number) => {
  console.info('ResponseCode :' + JSON.stringify(data));
  this.destroyRequest(httpRequest);
}).catch((err: Error) => {
  console.error("requestInStream ERROR : err = " + JSON.stringify(err));
  this.destroyRequest(httpRequest);
});

// 订阅
let res = new ArrayBuffer(0);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
  const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
  const resView = new Uint8Array(newRes);
  resView.set(new Uint8Array(res));
  resView.set(new Uint8Array(data), res.byteLength);
  res = newRes;
  console.info('res length: ' + res.byteLength);
});

// 用于订阅HTTP流式响应数据接收完毕事件。
httpRequest.on('dataEnd', () => {
  console.info('No more data in response, data receive end');
});

// 订阅HTTP流式响应数据接收进度事件,下载服务器的数据时,可以通过该回调获取数据下载进度。
httpRequest.on('dataReceiveProgress', (data: http.DataReceiveProgressInfo) => {
  console.info("dataReceiveProgress receiveSize:" + data.receiveSize + ", totalSize:" + data.totalSize);
});

// 订阅HTTP流式响应数据发送进度事件,向服务器上传数据时,可以通过该回调获取数据上传进度。
httpRequest.on('dataSendProgress', (data: http.DataSendProgressInfo) => {
  console.info("dataSendProgress receiveSize:" + data.sendSize + ", totalSize:" + data.totalSize);
});

public destroyRequest(httpRequest: http.HttpRequest) {
  // 取消订阅HTTP流式响应数据接收事件。
  httpRequest.off('dataReceive');
  // 取消订阅HTTP流式响应数据发送进度事件。
  httpRequest.off('dataSendProgress');
  // 取消订阅HTTP流式响应数据接收进度事件。
  httpRequest.off('dataReceiveProgress');
  // 取消订阅HTTP流式响应数据接收完毕事件。
  httpRequest.off('dataEnd');
  // 当该请求使用完毕时,调用destroy方法主动销毁。
  httpRequest.destroy();
}

on过的,必须要有对应的off

配置证书校验

可以通过预置应用级证书,或者预置证书公钥哈希值的方式来进行证书锁定,即只有开发者特别指定的证书才能正常建立HTTPS连接。两种方式都是在配置文件中配置的,配置文件在APP中的路径是:src/main/resources/base/profile/network_config.json

预置应用级证书

直接把证书原文件预置在APP中。目前支持crt和pem格式的证书文件。配置:

{
  "network-security-config": {
    "base-config": {
      "trust-anchors": [
        {
          "certificates": "/etc/security/certificates"
        }
      ]
    },
    "domain-config": [
      {
        "domains": [
          {
            "include-subdomains": true,
            "name": "example.com"
          }
        ],
        "trust-anchors": [
          {
            "certificates": "/data/storage/el1/bundle/entry/resources/resfile"
          }
        ]
      }
    ]
  }
}

预置证书公钥哈希值

通过在配置中指定域名证书公钥的哈希值,只允许使用公钥哈希值匹配的域名证书访问此域名。计算方法:

# 从证书中提取出公钥
openssl x509 -in www.example.com.pem -pubkey -noout > www.example.com.pubkey.pem
# 将pem格式的公钥转换成der格式
openssl asn1parse -noout -inform pem -in www.example.com.pubkey.pem -out www.example.com.pubkey.der
# 计算公钥的SHA256并转换成base64编码
openssl dgst -sha256 -binary www.example.com.pubkey.der | openssl base64

配置

{
  "network-security-config": {
    "domain-config": [
      {
        "domains": [
          {
            "include-subdomains": true,
            "name": "*.server.com"
          }
        ],
        "pin-set": {
          "expiration": "2024-11-08",
          "pin": [
            {
              "digest-algorithm": "sha256",
              "digest": "FEDCBA987654321"
            }
          ]
        }
      }
    ]
  }
}

配置不信任用户安装的CA证书

{
  "network-security-config": {
    ... ...
  },
  "trust-global-user-ca": false, // 配置是否信任企业MDM系统或设备管理员用户手动安装的CA证书,默认为true
  "trust-current-user-ca" : false // 配置是否信任当前用户安装的CA证书,默认为true
}

配置明文访问权限

// src/main/resources/base/profile/network_config.json
{
  "network-security-config": {
    "base-config": {
      "cleartextTrafficPermitted": true // 可选,自API 20开始支持该属性。
    },
    "domain-config": [
      {
        "domains": [
          {
            "include-subdomains": true,
            "name": "example.com"
          }
        ],
        "cleartextTrafficPermitted": false // 可选,自API 20开始支持该属性。
      }
    ],
    "component-config": {
        "Network Kit": true, // 可选,自API 20开始支持该属性。
        "ArkWeb": false // 可选,自API 20开始支持该属性。
    }
  }
}

注意cleartextTrafficPermitted

RCP

@hms.collaboration.rcp(后续简称RCP)指的是远程通信平台(remote communication platform),RCP提供了网络数据请求功能,相较于Network Kit中HTTP请求能力,RCP更具易用性,且拥有更多的功能。

一个Patch的例子(只更改特定字段,而不是post整个对象):

// 定义请求头
let headers: rcp.RequestHeaders = {
  'accept': 'application/json'
};
// 定义要修改的内容
let modifiedContent: UserInfo = {
  'userName': 'xxxxxx'
};
const securityConfig: rcp.SecurityConfiguration = {
  tlsOptions: {
    tlsVersion: 'TlsV1.3'
  }
};
// 创建通信会话对象
const session = rcp.createSession({ requestConfiguration: { security: securityConfig } });
// 定义请求对象rep
let req = new rcp.Request('http://example.com/fetch', 'PATCH', headers, modifiedContent);
// 发起请求
session.fetch(req).then((response) => {
  Logger.info(`Request succeeded, message is ${JSON.stringify(response)}`);
}).catch((err: BusinessError) => {
  Logger.error(`err: err code is ${err.code}, err message is ${JSON.stringify(err)}`);
});
  • rcp创建一个session
  • rcp创建一个request
  • session.fetch(request)

公共配置

用来设置基地址, 请求头等:

// 定义sessionConfig对象
const sessionConfig: rcp.SessionConfiguration = {
  baseAddress: 'http://api.example.com',
  headers: {
    'authorization': 'Bearer YOUR_ACCESS_TOKEN',
    'content-type': 'application/json'
  },
  requestConfiguration:{
    security:{
      tlsOptions: {
        tlsVersion: 'TlsV1.3'
      }
    }
  }
};
// 创建通信会话对象,并传入sessionConfig
const session = rcp.createSession(sessionConfig);

更多内容见文档,如双向证书校验,请求和响应的拦截等

存储/持久化

首先项(Preferences)

  • 用户首选项(Preferences)为应用提供Key-Value键值型的数据处理能力,支持应用持久化轻量级数据,并对其修改和查询。
  • 底层是XML文件(从API version 18开始,可选择GSKV存储模式。)
    • xml, 数据操作发生在内存中,通过flush进行持久化, 适用简单,单进程场景
    • gskv, 二进制存储, 实时落盘,支持多进程并发读写,不支持跨平台
  • key长度不超过1024字节, xml value不超过50M
import { preferences } from '@kit.ArkData';

import { UIAbility } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { window } from '@kit.ArkUI';

let dataPreferences: preferences.Preferences | null = null;

class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage) {
    // 需要先判断是否支持gskv
    let isGskvSupported = preferences.isStorageTypeSupported(preferences.StorageType.GSKV);

    let options: preferences.Options = { name: 'myStore', storageType: preferences.StorageType.GSKV };
    dataPreferences = preferences.getPreferencesSync(this.context, options);
  }
}

import { util } from '@kit.ArkTS';
if (dataPreferences.hasSync('startup')) {
  console.info("The key 'startup' is contained.");
} else {
  console.info("The key 'startup' does not contain.");
  // 此处以此键值对不存在时写入数据为例
  dataPreferences.putSync('startup', 'auto');
  // 在XML模式下,当字符串包含非UTF-8格式的字符时,需要将字符串转为Uint8Array类型再存储,长度均不超过16 * 1024 * 1024个字节。
  let uInt8Array1 = new util.TextEncoder().encodeInto("~!@#¥%……&*()——+?");
  dataPreferences.putSync('uInt8', uInt8Array1);
}

let val = dataPreferences.getSync('startup', 'default');
console.info("The 'startup' value is " + val);
let uInt8Array2 : preferences.ValueType = dataPreferences.getSync('uInt8', new Uint8Array(0));
// 将获取到的Uint8Array转换为字符串
let textDecoder = util.TextDecoder.create('utf-8');
val = textDecoder.decodeToString(uInt8Array2 as Uint8Array);
console.info("The 'uInt8' value is " + val);

kvo

let observer = (key: string) => {
  console.info('The key' + key + 'changed.');
}
dataPreferences.on('change', observer);

SQLite

  • 单次查询数据量不超过5000条。
  • TaskPool中查询。(任务池@ohos.taskpool)
  • 数据库中常驻有4个读连接和1个写连接。读连接会动态扩充,写连接不会动态扩充(等待)
  • ArkTS侧支持的基本数据类型:number、string、二进制类型数据、boolean。
  • 为保证插入并读取数据成功,建议一条数据不要超过2M。超出该大小,插入成功,读取失败。

更多见文档