HarmonyOS NEXT 高级

DFX

Performance Analysis Kit

HiLog

import { hilog } from '@kit.PerformanceAnalysisKit';
hilog.isLoggable(0xFF00, "testTag", hilog.LogLevel.INFO);
hilog.info(0xFF00, "testTag", "%{public}s World %{public}d", "hello", 3);

setMinLogLevel(level: LogLevel)
setLogLevel(level: LogLevel, prefer: PreferStrategy)
  • 头两个参数是domain和tag,用isLoggable判断时,主要这两个参数也需要对上
  • %{public}s%{public}d是占位符,%{public}是可选的,表示这个参数是公共的,可以被其他模块使用,%{private}表示私有
    • 其实就是%s,加上了public这种隐私修饰符
    • 格式化类型有s, d/i, o/O(object的意思)
  • hilog的日志级别有DEBUGINFOWARNERRORFATALDEBUG级别最低,FATAL级别最高

HiAppEvent

  • 提供事件打点机制
  • 通过HiAppEvent的接口addWatcher,开发者可以注册监听自己关注的系统事件或应用事件。
    • 系统事件信息的存放路径不在应用沙箱目录中,因此无法直接获取。
    • 应用事件需要开发者主动触发,调用write接口进行打点
  • 订阅接口addWatcher是同步接口,涉及IO操作。对于性能有要求的模块,建议将接口的调用放到非主线程。
  • 订阅接口addWatcher传入的名称name是唯一的,相同的name,后一次调用会覆盖前一次的订阅。

API文档

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

// 订阅崩溃事件
// EntryAbility.ets
hiAppEvent.addWatcher({
  // 开发者可以自定义观察者名称,系统会使用名称来标识不同的观察者
  name: "AppCrashWatcher",
  // 订阅过滤条件,这里是订阅了系统事件中的崩溃事件
  appEventFilters: [
    {
      domain: hiAppEvent.domain.OS,
      names: [hiAppEvent.event.APP_CRASH]
    }
  ],
  // 实现onReceive回调,监听到事件后实时回调
  onReceive: (domain: string, appEventGroups: Array<hiAppEvent.AppEventGroup>) => {
    hilog.info(0x0000, 'testTag', `domain=${domain}`);
    for (const eventGroup of appEventGroups) {
      hilog.info(0x0000, 'testTag', `HiAppEvent eventName=${eventGroup.name}`);
      for (const eventInfo of eventGroup.appEventInfos) {
        // 开发者可以获取到崩溃事件发生的时间戳
        hilog.info(0x0000, 'testTag', `HiAppEvent eventInfo.params.time=${JSON.stringify(eventInfo.params['time'])}`);
        // 开发者可以获取到崩溃应用的包名
        hilog.info(0x0000, 'testTag', `HiAppEvent eventInfo.params.bundle_name=${JSON.stringify(eventInfo.params['bundle_name'])}`);
        // 开发者可以获取到崩溃事件发生时的故障日志文件
        hilog.info(0x0000, 'testTag', `HiAppEvent eventInfo.params.external_log=${JSON.stringify(eventInfo.params['external_log'])}`);
      }
    }
  }
});

// 触发崩溃
// Index.ets
Button("appCrash")
  .onClick(()=>{
    let result: object = JSON.parse("");
  })

订阅应用事件(如按钮点击)

// EntryAbility.ets
hiAppEvent.addWatcher({
  // 开发者可以自定义观察者名称,系统会使用名称来标识不同的观察者
  name: "ButtonClickWatcher",
  // 开发者可以订阅感兴趣的应用事件,此处是订阅了按钮事件
  appEventFilters: [{ domain: "button" }],
  // 开发者可以设置订阅回调触发的条件,此处是设置为事件打点数量满足1个
  triggerCondition: { row: 1 },
  // 开发者可以自行实现订阅回调函数,以便对订阅获取到的事件打点数据进行自定义处理
  onTrigger: (curRow: number, curSize: number, holder: hiAppEvent.AppEventPackageHolder) => {
    // 如果返回的holder对象为null,表示订阅过程发生异常。因此,在记录错误日志后直接返回
    if (holder == null) {
      hilog.error(0x0000, 'testTag', "HiAppEvent holder is null");
      return;
    }
    hilog.info(0x0000, 'testTag', `HiAppEvent onTrigger: curRow=%{public}d, curSize=%{public}d`, curRow, curSize);
    let eventPkg: hiAppEvent.AppEventPackage | null = null;
    // 根据设置阈值大小(默认为1条事件)去获取订阅事件包,直到将订阅数据全部取出
    // 返回的事件包对象为null,表示当前订阅数据已被全部取出,此次订阅回调触发结束
    while ((eventPkg = holder.takeNext()) != null) {
      // 开发者可以对事件包中的事件打点数据进行自定义处理,此处是将事件打点数据打印在日志中
      hilog.info(0x0000, 'testTag', `HiAppEvent eventPkg.packageId=%{public}d`, eventPkg.packageId);
      hilog.info(0x0000, 'testTag', `HiAppEvent eventPkg.row=%{public}d`, eventPkg.row);
      hilog.info(0x0000, 'testTag', `HiAppEvent eventPkg.size=%{public}d`, eventPkg.size);
      for (const eventInfo of eventPkg.data) {
        hilog.info(0x0000, 'testTag', `HiAppEvent eventPkg.info=%{public}s`, eventInfo);
      }
    }
  }
});

// 事件打点
// Index.ets
Button("buttonClick")
  .onClick(()=>{
    // 在按钮点击函数中进行事件打点,以记录按钮点击事件
    let eventParams: Record<string, number> = { "click_time": 100 };
    let eventInfo: hiAppEvent.AppEventInfo = {
      // 事件领域定义
      domain: "button",
      // 事件名称定义
      name: "click",
      // 事件类型定义
      eventType: hiAppEvent.EventType.BEHAVIOR,
      // 事件参数定义
      params: eventParams
    };
    hiAppEvent.write(eventInfo).then(() => {
      hilog.info(0x0000, 'testTag', `HiAppEvent success to write event`);
    }).catch((err: BusinessError) => {
      hilog.error(0x0000, 'testTag', `HiAppEvent err.code: ${err.code}, err.message: ${err.message}`);
    });
    hilog.info(0x0000, 'testTag', `HiAppEvent write event`);
  })

  • 应用内事件的domain是字符串,并且在addWatcher里填filter里应用的也是domain,不知道能不能过滤得更细致
  • 在HiLog窗口搜索“HiAppEvent”关键字可快速过滤出系统和应用事件

HiTraceMeter

  • HiTraceMeter提供系统性能打点接口。开发者在关键代码位置调用这些API,能够有效跟踪进程轨迹,查看系统和应用性能。
  • HiTraceMeter打点接口分为三类:同步时间片跟踪、异步时间片跟踪和整数跟踪。
    • HiTraceMeter接口实现均为同步,同步和异步针对的是被跟踪的业务。
  • HiTraceMeter打点接口可与HiTraceChain一起使用,进行跨设备、跨进程或跨线程的打点关联与分析。

API文档

// index.ets

import { hiTraceMeter, hilog } from '@kit.PerformanceAnalysisKit';
@Entry
@Component
struct Index {
  @State message: string = 'Hello World';
  build() {
    Row() {
      Column() {
        Text(this.message)
          .fontSize(50)
          .fontWeight(FontWeight.Bold)
          .onClick(() => {
            this.message = (this.message == 'Hello HiTrace') ? 'Hello World' : 'Hello HiTrace';
            const COMMERCIAL = hiTraceMeter.HiTraceOutputLevel.COMMERCIAL;
            let traceCount = 0;
            // 第一个异步跟踪任务开始
            hiTraceMeter.startAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1001, 'categoryTest', 'key=value');
            // 开始计数任务
            traceCount++;
            hiTraceMeter.traceByValue(COMMERCIAL, 'myTestCountTrace', traceCount);
            // 业务流程
            hilog.info(0x0000, 'testTrace', 'myTraceTest running, taskId: 1001');
            // 第二个异步跟踪任务开始,同时第一个跟踪的同名任务还没结束,出现了并行执行,对应接口的taskId需要不同
            hiTraceMeter.startAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1002, 'categoryTest', 'key=value');
            // 开始计数任务
            traceCount++;
            hiTraceMeter.traceByValue(COMMERCIAL, 'myTestCountTrace', traceCount);
            // 业务流程
            hilog.info(0x0000, 'testTrace', 'myTraceTest running, taskId: 1002');
            // 结束taskId为1001的异步跟踪任务
            hiTraceMeter.finishAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1001);
            // 结束taskId为1002的异步跟踪任务
            hiTraceMeter.finishAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1002);
            
            // 开始同步跟踪任务
            hiTraceMeter.startSyncTrace(COMMERCIAL, 'myTestSyncTrace', 'key=value');
            // 业务流程
            hilog.info(0x0000, 'testTrace', 'myTraceTest running, synchronizing trace');
            // 结束同步跟踪任务
            hiTraceMeter.finishSyncTrace(COMMERCIAL);
            
            // 若通过HiTraceMeter性能打点接口传递的参数的生成过程比较复杂,此时可以通过isTraceEnabled判断当前是否开启应用trace捕获,
            // 在未开启应用trace捕获时,避免该部分性能损耗
            if (hiTraceMeter.isTraceEnabled()) {
                let customArgs = 'key0=value0';
                for(let index = 1; index < 10; index++) {
                    customArgs += `,key${index}=value${index}`
                }
                hiTraceMeter.startAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1003, 'categoryTest', customArgs);
                hilog.info(0x0000, 'testTrace', 'myTraceTest running, taskId: 1003');
                hiTraceMeter.finishAsyncTrace(COMMERCIAL, 'myTestAsyncTrace', 1003);
            } else {
                hilog.info(0x0000, 'testTrace', 'myTraceTest running, trace is not enabled');
            }
          })
       }
       .width('100%')
     }
     .height('100%')
   }
}

在DevEco Studio Terminal窗口中执行以下命令,开启应用的trace捕获。

PS D:\xxx\xxx> hdc shell
$ hitrace --trace_begin app

执行如下命令抓取trace数据,并使用“myTest”关键字过滤trace数据(示例打点接口传递的name字段前缀均为“myTest”)。

$ hitrace --trace_dump | grep myTest

成功抓取到:

e.myapplication-39945   (  39945) [010] .... 347921.342267: tracing_mark_write: S|39945|H:myTestAsyncTrace|1001|M62|categoryTest|key=value
e.myapplication-39945   (  39945) [010] .... 347921.342280: tracing_mark_write: C|39945|H:myTestCountTrace|1|M62
e.myapplication-39945   (  39945) [010] .... 347921.342327: tracing_mark_write: S|39945|H:myTestAsyncTrace|1002|M62|categoryTest|key=value
e.myapplication-39945   (  39945) [010] .... 347921.342333: tracing_mark_write: C|39945|H:myTestCountTrace|2|M62
e.myapplication-39945   (  39945) [010] .... 347921.342358: tracing_mark_write: F|39945|H:myTestAsyncTrace|1001|M62
e.myapplication-39945   (  39945) [010] .... 347921.342365: tracing_mark_write: F|39945|H:myTestAsyncTrace|1002|M62
e.myapplication-39945   (  39945) [010] .... 347921.342387: tracing_mark_write: B|39945|H:myTestSyncTrace|M62|key=value
e.myapplication-39945   (  39945) [010] .... 347921.342586: tracing_mark_write: S|39945|H:myTestAsyncTrace|1003|M62|categoryTest|key0=value0,key1=value1,key2=value2,key3=value3,key4=value4,key5=value5,key6=value6,key7=value7,key8=value8,key9=value9
e.myapplication-39945   (  39945) [010] .... 347921.342615: tracing_mark_write: F|39945|H:myTestAsyncTrace|1003|M62

停止:

$ hitrace --trace_finish

使用DevEco Studio Profiler工具可以可视化展示HiTraceMeter日志内容,分析应用或服务的CPU使用率和线程运行状态,查看指定时间段内程序在CPU上的执行耗时。具体使用指导请参考CPU活动分析:CPU分析

导出日志到当前文件夹

$ exit
PS D:\xxx\xxx> hdc file recv /data/local/tmp/trace.ftrace ./

可视化

  • 在DevEco Studio Profiler的会话区选择“Open File”,将HiTraceMeter文本日志导入DevEco Studio。
  • 通过Smartperf_Host工具进行分析,工具下载链接:developtools_smartperf_host 发行版

ArkTS高性能编程

基于TypeScript,但限制了一些特性

  • 不支持动态属性变更,变量/参数需要有明确的类型声明和返回值声明等
  • 禁用了@ts-ignore, @ts-expect-error等屏蔽编译校验的命令
  • 强制严格模式:严格判空,严格函数类型检查,严格成员初始化等
  • 不支持any, unknown
    • 使用泛型,联合类型或object
    • 跨语言时可使用ESObject

冷启动过程

  • 应用进程创建和初始化: 设置合适分辨率的startWindowIcon
  • App和Ability初始化: 减少首页ability和page中的import
  • Ability生命周期: 使用异步加载
  • 加载绘制首页:延迟加载

减少卡顿丢帧

  • 避免在主线程上执行耗时操作,交给TaskPool或者Worder在后台线程中执行
  • 减少渲染进程的冗余开销,用资源图代替绘制,合理使用RenderGroup,尺寸和位置尽量使用整数等
  • 减少视图嵌套层级
  • 组件复用配合LazyForEach
  • 精确控制状态变量关联组件数
  • 对象上谨慎使用状态变量关联
    • @prop是深拷贝,@ObjectLink是浅拷贝,优先使用浅拷贝方案减少系统内存开销

使用性能调优工具

  • ArkUlInspector:用于检查和调试应用程序页面布局的情况
  • Launch Insight:录制和还原从启动应用,到显示首帧过程中的CPU、内存等资源使用情况,用于分析启动耗时长的问题。
  • Frame Insight:录制卡顿过程中的关键数据,标注出应用侧、RenderService 侧卡顿帧,用于分析应用卡顿、丢帧的问题。
  • Time Insight:通过周期性采集调用栈,识别CPU 耗时高的热点代码段,用于分析卡顿、CPU 占用高、运行速度慢等问题。
  • Allocation Insight:录制和分析内存分配记录,用于分析内存峰值高,内存泄漏,内存不足导致应用被强杀等问题。
  • Snapshot Insight:录制和分析应用程序中 ArkTS 对象的分布,通过快照方式对比 ArkTS 对象分布区别,用于分析内存泄漏问题。
  • CPU Insight:录制cPU调度事件、线程运行状态、CPU核频率、Trace 等数据,可用于分析卡顿、运行速度慢、应用无响应等问题。
  • Smart Perf:开源性能调优平台,支持对CPU调度、频点、进程线程时间片、堆内存、帧率等数据进行采集和展示,展示方式为泳道图

丢帧

HarmonyOS图形系统采用统一渲染模式,遵循流水线模式。

  • 90Hz刷新率下,每个Vsync周期是11.1ms。
  • 60Hz刷新率下,每个Vsync周期是16.7ms。
  • 120Hz刷新率下,每个Vsync周期是8.3ms。

多端开发

官方描述是一次开发,多端部署,其实就是开发的时候按多平台或多屏幕大小把代码都写好了,或者利用他们提供的API提供多段代码,总之就是要增加代码量,而不是真的全自动适配。

自适应布局 & 响应式布局

  • 自适应代表根据容器大小自动适配,目前提供7种能力:拉伸能力、均分能力、占比能力、缩放能力、延伸能力、隐藏能力、折行能力。
  • 响应式则是预设几种布局进行有级的不连续的变化,当前响应式布局能力有3种:断点、媒体查询、栅格布局。

也就是说自适应是无级变化的,取决于容器大小,响应式是预设的几种布局进行“突变”。

系统能力

系统能力(即SystemCapability,缩写为SysCap)指操作系统中每一个相对独立的特性,如蓝牙,WIFI,NFC,摄像头等,都是系统能力之一。每个系统能力对应多个API,随着目标设备是否支持该系统能力共同存在或消失。用三个概念来实现:

  • 支持能力集:设备具备的系统能力集合,在设备配置文件中配置。
  • 要求能力集:应用需要的系统能力集合,在应用配置文件中配置。
  • 联想能力集:开发应用时DevEco Studio可联想的API所在的系统能力集合,在应用配置文件中配置。

设备具体能能力难道不是由设备厂商决定的吗?为什么要在应用配置文件中配置?

动态判断: 要求能力里面没有配置的能力,可以通过canIUse方法判断设备是否支持该能力。

if (canIUse("SystemCapability.Communication.NFC.Core")) {
   console.log("该设备支持SystemCapability.Communication.NFC.Core");
} else {
    console.log("该设备不支持SystemCapability.Communication.NFC.Core");
}

或者通过import来导入,不具有该能力的话会得到undefined

import controller from '@kit.ConnectivityKit';
try {
    controller.enableNfc();
    console.log("controller enableNfc success");
} catch (busiError) {
    console.log("controller enableNfc busiError: " + busiError);
}

配置样例:

// syscap.json
{
    "devices": {
        "general": [            // 每一个典型设备对应一个syscap支持能力集,可配置多个典型设备
            "default",
            "tablet"
        ],
        "custom": [             // 厂家自定义设备
            {
                "某自定义设备": [
                    "SystemCapability.Communication.SoftBus.Core"
                ]
            }
        ]
    },
    "development": {             // addedSysCaps内的sycap集合与devices中配置的各设备支持的syscap集合的并集共同构成联想能力集
        "addedSysCaps": [
            "SystemCapability.Communication.NFC.Core"
        ]
    },
    "production": {              // 用于生成rpcid,慎重添加,可能导致应用无法分发到目标设备上
        "addedSysCaps": [],      // devices中配置的各设备支持的syscap集合的交集,添加addedSysCaps集合再除去removedSysCaps集合,共同构成要求能力集
        "removedSysCaps": []     // 当该要求能力集为某设备的子集时,应用才可被分发到该设备上
    }
}
  1. 先回答上面的问题,系统能力集为什么需要配置?原来是配置一下硬件平台,比如上例中的defaulttablet,而不是真的一条条写出来。即devices中指定的设备集合决定了系统能力集
  2. addedSysCaps配置的是要求能力集,一个工程里真正请求能力的地方
  • 这里没有写到数组里的,仍然可以用上一节里的动态方式在代码中使用

工程组织

  • 一个应用包含一个或多个Module(含源代码,资源文件,三方库及应用/服务配置文件)
  • Module分为“Ability”和“Library”两种类型:
    • “Ability”类型的Module编译后生成HAP包。
    • “Library”类型的Module编译后生成HAR包。
  • HarmonyOS的应用以APP Pack形式发布,其包含一个或多个HAP包。
  • HAP是HarmonyOS应用安装的基本单位,HAP可以分为EntryFeature两种类型:
    • Entry类型的HAP:应用的主模块。在同一个应用中,同一设备类型只支持一个Entry类型的HAP,通常用于实现应用的入口界面、入口图标、主特性功能等。
    • Feature类型的HAP:应用的动态特性模块。Feature类型的HAP通常用于实现应用的特性功能,一个应用程序包可以包含一个或多个Feature类型的HAP,也可以不包含。

部署方式

  1. 为不同设备一次编译生成相同的HAP,这一般是代码里写全了
  2. 为不同设备一次编译生成不同的HAP,说明代码里不是把所有场景写全,而是写在不同的地方,生成不同的HAP

从屏幕尺寸、输入方式及交互距离三个维度考虑,可以将常用类型的设备分为不同泛类:

  • 默认设备、平板
  • 车机、智慧屏
  • 智能穿戴
  • …… 一般是如果为同一泛类开发,则选用模式一,在if-else里写全,然后生成一个包即可,如果跨了泛类,则需要为不同泛类生成不同的hap

工程结构

一般采用三层结构:

  • common(公共能力层):用于存放公共基础能力集合(如工具库、公共配置等)。
    • common层不可分割,需编译成一个HAR包,其只可以被products和features依赖,不可以反向依赖。
  • features(基础特性层):用于存放基础特性集合(如应用中相对独立的各个功能的UI及业务逻辑实现等)。
    • 各个feature高内聚、低耦合、可定制,供产品灵活部署。
    • 不需要单独部署的feature通常编译为HAR包,供products或其它feature使用。
    • 需要单独部署的feature通常编译为Feature类型的HAP包,和products下Entry类型的HAP包进行组合部署。
    • features层可以横向调用及依赖common层,同时可以被products层不同设备形态的HAP所依赖,但是不能反向依赖products层。
  • products(产品定制层):用于针对不同设备形态进行功能和特性集成。
    • products层各个子目录各自编译为一个Entry类型的HAP包,作为应用主入口。products层不可以横向调用。

如下所示:

/application
├── common                  # 可选。公共能力层, 编译为HAR包或HSP包
├── features                # 可选。基础特性层
│   ├── feature1            # 子功能1, 编译为HAR包或HSP包或Feature类型的HAP包
│   ├── feature2            # 子功能2, 编译为HAR包或HSP包或Feature类型的HAP包
│   └── ...
└── products                # 必选。产品定制层
    ├── wearable            # 智能穿戴泛类目录, 编译为Entry类型的HAP包
    ├── default             # 默认设备泛类目录, 编译为Entry类型的HAP包
    └── ...

示例

// features/live/src/main/ets/view/LivePage.ets
@Component
export struct LivePage {
  @StorageLink('topRectHeight') topRectHeight: number = 0;

  build() {
    Column() {
      Header()
      LiveList()
    }
    .width(LiveConstants.FULL_WIDTH_PERCENT)
    .height(LiveConstants.FULL_HEIGHT_PERCENT)
    .padding({ top: this.getUIContext().px2vp(this.topRectHeight) })
  }
}

// features/live/src/main/ets/view/Header.ets
@Component
export struct Header {
  build() {
    Row() {
      Image($r('app.media.ic_back'))
        ...
      Text($r('app.string.live_title'))
        ...
    }
    .height($r('app.float.title_row_height'))
    .width(LiveConstants.FULL_WIDTH_PERCENT)
  }
}

// features/live/src/main/ets/view/LiveList.ets
@Component
export struct LiveList {
  ...

  build() {
    GridRow({
      columns: { sm: LiveConstants.FOUR_COLUMN, md: LiveConstants.EIGHT_COLUMN, lg: LiveConstants.TWELVE_COLUMN },
      breakpoints: { value: [LiveConstants.SMALL_DEVICE_TYPE, LiveConstants.MIDDLE_DEVICE_TYPE,
        LiveConstants.LARGE_DEVICE_TYPE] },
      gutter: { x: $r('app.float.grid_row_gutter') }
    }) {
      GridCol({
        span: { sm: LiveConstants.FOUR_COLUMN, md:LiveConstants.SIX_COLUMN, lg: LiveConstants.EIGHT_COLUMN },
        offset: { sm: LiveConstants.ZERO_COLUMN, md: LiveConstants.ONE_COLUMN, lg: LiveConstants.TWO_COLUMN }
      }) {
        Scroll(this.scroller) {
          Column() {
            ForEach(this.liveStreams, (item: LiveStream, index: number) => {
              this.LiveItem(item, index)
            }, (item: LiveStream, index: number) => index + JSON.stringify(item))
          }
          .width(LiveConstants.FULL_WIDTH_PERCENT)
        }
        ...
      }
      ...
    }
    ...
  }
  ...
}

上例中,断点是手动设置的(320, 600, 840),设置columns里的smmdlg,表示不同断点下的列数,就是不知道是不是一一对应的,比如知道320对应sm,那如果没有传够3个呢?

GridRow({
    breakpoints: {
      value: BreakpointConstants.BREAKPOINT_VALUE,
      reference: BreakpointsReference.WindowSize
    },
    columns: {
      sm: BreakpointConstants.COLUMN_LG,
      md: BreakpointConstants.COLUMN_LG,
      lg: BreakpointConstants.COLUMN_LG
    }
  }) {
  GridCol({
    span: { sm: GridConstants.SPAN_FOUR, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
  }) {
    this.CoverImage()
  }

  GridCol({
    span: { sm: GridConstants.SPAN_EIGHT, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
  }) {
    this.CoverIntroduction()
  }

  GridCol({
    span: { sm: GridConstants.SPAN_TWELVE, md: GridConstants.SPAN_TWELVE, lg: GridConstants.SPAN_TWELVE }
  }) {
    this.CoverOptions()
  }
}
  1. 以(320, 600, 840)设置断点
  2. 配置大中小屏幕栅格数均为12(COLUMN_LG
  3. 三列的配置是小屏幕(4,8,12),中屏幕(12,12,12),大屏幕(12,12,12),意思是小屏幕下元素1,2占1行,元素3单独占1行,中大屏幕上则是各占一行。

span就是在栅格系统里跨几列,offset就是偏移几列

Column() {
this.PlayAll()
List() {
  LazyForEach(new SongDataSource(this.songList), (item: SongItem, index: number) => {
    ListItem() {
      Column() {
        this.SongItem(item, index)
      }
      .padding({
        left: $r('app.float.list_item_padding'),
        right: $r('app.float.list_item_padding')
      })
    }
  }, (item: SongItem, index?: number) => JSON.stringify(item) + index)
}
.width(StyleConstants.FULL_WIDTH)
.backgroundColor(Color.White)
.margin({ top: $r('app.float.list_area_margin_top') })
.lanes(this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG ?
  ContentConstants.COL_TWO : ContentConstants.COL_ONE)
.layoutWeight(1)
  1. 这里配置lanes用了条件表达式,而不再是提供{sm:xx, md:xx, lg:xx}这种对象,
  2. 结果是大屏下list显示两列,其它情况显示1列

breakpoint相关源码在BreakpointSystem.ets

在没有{sm:xx, md:xx, lg:xx}这种标准API的情况下,参考下面的例子根据断点配置不同的具体值:

xxx()
.width(new BreakpointType({
  sm: $r('app.float.play_width_sm'),
  md: $r('app.float.play_width_sm'),
  lg: $r('app.float.play_width_lg')
}).getValue(this.currentBreakpoint))

也就是BreakpointType类,这个类可以传入一个对象,对象里配置不同断点下的值,然后通过getValue方法传入当前断点,返回对应的值。

// features/musicList/src/main/ets/components/MusicControlComponent.ets
@Component
export struct MusicControlComponent {
  ...

  build() {
    ...
      Row() {
        if (this.isFoldFull) {
          Column() {
            ...
            GridRow({
              columns: { md: BreakpointConstants.COLUMN_MD },
              gutter: BreakpointConstants.GUTTER_MUSIC_X
            }) {
              GridCol({
                span: { md: BreakpointConstants.SPAN_SM }
              }) {
                MusicInfoComponent()
              }
              .margin({
                left: $r('app.float.margin_small'),
                right: $r('app.float.margin_small')
              })
              GridCol({
                span: { md: BreakpointConstants.SPAN_SM }
              }) {
                LyricsComponent({ isShowControl: this.isShowControlLg, isTablet: this.isTabletFalse })
              }
              .padding({
                left: $r('app.float.twenty_four')
              })
            }
            ...
          }
          ...
        } else if (this.currentBreakpoint === BreakpointConstants.BREAKPOINT_LG) {
          Column() {
            ...
            GridRow({
              columns: { md: BreakpointConstants.COLUMN_MD, lg: BreakpointConstants.COLUMN_LG },
              gutter: BreakpointConstants.GUTTER_MUSIC_X
            }) {
              GridCol({
                span: { md: BreakpointConstants.SPAN_SM, lg: BreakpointConstants.SPAN_SM },
                offset: { lg: BreakpointConstants.OFFSET_MD }
              }) {
                Column() {
                  Image(this.songList[this.selectIndex].label)
                    .width(StyleConstants.FULL_WIDTH)
                    .aspectRatio(1)
                    .borderRadius($r('app.float.cover_radius'))
                  ControlAreaComponent()
                }
                .height(StyleConstants.FULL_HEIGHT)
                .justifyContent(FlexAlign.SpaceBetween)
                .margin({
                  bottom: $r('app.float.common_margin')
                })
              }
              GridCol({
                span: { md: BreakpointConstants.SPAN_SM, lg: BreakpointConstants.SPAN_MD },
                offset: { lg: BreakpointConstants.OFFSET_MD }
              }) {
                LyricsComponent({ isShowControl: this.isShowControlLg, isTablet: this.isTablet })
              }
            }
            ...
          }
        } else {
          Stack({ alignContent: Alignment.TopStart }) {
            Swiper() {
              MusicInfoComponent()
                .margin({
                  top: $r('app.float.music_component_top'),
                  bottom: $r('app.float.music_component_bottom')
                })
                .padding({
                  left: $r('app.float.common_padding'),
                  right: $r('app.float.common_padding')
                })
              LyricsComponent({ isShowControl: this.isShowControl, isTablet: this.isTabletFalse })
                .margin({
                  top: $r('app.float.margin_lyric')
                })
                .padding({
                  left: $r('app.float.common_padding'),
                  right: $r('app.float.common_padding')
                })
            }
            ...
          }
          .height(StyleConstants.FULL_HEIGHT)
        }
      }
      .padding({
        bottom: this.bottomArea,
        top: this.topArea
      })
    ...
  }
  ...
}

上述代码做了更极端的例子,引入了折叠屏的布局。 不过先插一个题外话,看了这么多段代码,应该也看出来了:

  • Column的子组件是 GridRow/GridCol​ 时,布局由栅格系统主导。
  • Column的子组件是普通视图组件或嵌套的Flex容器(如 Row、另一个 Column)​ 时,布局由Flexbox系统主导。
  • 当然,GridRow里面应该也只能是GridCol了吧

看回代码,

  • 这是一个音乐播放的界面,截取部分是音乐信息和歌词的展示。
  • 在折叠屏下,定义了一个MD的横向栅格系统(GridRow),然后让两个组件分别占据sm的宽度
  • 超大屏下,则是sm+md的组合, 也就是说右侧的歌词组件可能更宽,当然,还是保持了一屏展示两个组件的布局。
  • 中小屏幕下,则由Swiper来切换了,一次只能看到一个。而且,甚至都没用到栅格系统

值得说的代码是,要兼顾这么多情况,只能靠if-else硬编码了,没有API能直接实现,所以所谓的响应式,不过是把所有场景都写全了。

然后,LG的场景里,配置columns的时候,传入了{md, lg},这里是没必要的,因为已经确定了此代码块内一定是lg

默认值

  • 断点默认值:{ value: ["320vp", "600vp", "840vp"], reference: BreakpointsReference.WindowSize }
  • 栅格数默认值:{ xs: 2, sm: 4, md: 8, lg: 12, xl: 12, xxl: 12 }

自由流转

  • 在HarmonyOS中,将跨多设备的分布式操作统称为流转;根据使用场景的不同,流转又分为跨端迁移多端协同两种具体场景。
  • 在应用开发层面,跨端迁移指在A端运行的UIAbility迁移到B端上,完成迁移后,B端UIAbility继续任务,而A端UIAbility可按需决定是否退出。
    • 典型场景:媒体播控,应用接续
  • 在应用开发层面,多端协同指多端上的不同UIAbility/ServiceExtensionAbility同时运行、或者交替运行实现完整的业务;或者多端上的相同UIAbility/ServiceExtensionAbility同时运行实现完整的业务。
    • 典型场景:跨设备拖拽/剪贴板,跨设备互通

分布式应用开发模型

  • 分布式软总线:发现,连接,组网,传输
  • 分布式数据管理:
    • 分布式数据库,kvstore/rdb,
    • 分布式数据对象,一般保存在内存中,过程数据
    • 系统公共数据访问
  • 分布式硬件:硬件共享,超级终端,硬件资源池,软件定义硬件
  • 分布式任务调度:提供任务跨端迁移、多端协同能力

跨端迁移

  1. 配置迁移功能
// src/main/resources/base/element/string.json
{
  "module": {
    // ...
    "abilities": [
      {
        // ...
        "continuable": true,
        "launchType": "singleton"
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.DISTRIBUTED_DATASYNC",
        "reason": "$string:distributed_data_sync", 
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      }
    ]
  }
}
  1. 向用户申请授权
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';

export default class EntryAbility extends UIAbility {
  async checkPermissions(): Promise<void> {
    const permissions: Array<Permissions> = ["ohos.permission.DISTRIBUTED_DATASYNC"];
    const accessManager = abilityAccessCtrl.createAtManager();
    try {
      const bundleFlags = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION;
      const bundleInfo = await bundleManager.getBundleInfoForSelf(bundleFlags);
      const grantStatus = await accessManager.checkAccessToken(bundleInfo.appInfo.accessTokenId, permissions[0]);

      if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) {
        accessManager.requestPermissionsFromUser(this.context, permissions);
      }
    } catch (err) {
      Logger.error('EntryAbility', 'checkPermissions', `Catch err: ${err}`);
      return;
    }
  }
}

这里与iOS有异,iOS下DENIED表明用户手动禁止了,是不能再弹窗的,只有未确定或未授过权才需要弹窗

  1. 实现迁移
export default class EntryAbility extends UIAbility {
  private localObject: distributedDataObject.DataObject | undefined = undefined;
  private targetDeviceId: string | undefined = undefined;
  private changeCall: (sessionId: string, fields: Array<string>) => void =
    (sessionId: string, fields: Array<string>) => {
      if (fields != null && fields != undefined && this.localObject != undefined) {
        for (let index: number = 0; index < fields.length; index++) {
          if (fields[index] === 'recipient') {
            AppStorage.setOrCreate<string>('recipient', this.localObject['recipient'] as string);
          } else if (fields[index] === 'sender') {
            AppStorage.setOrCreate<string>('sender', this.localObject['sender'] as string);
          } else if (fields[index] === 'subject') {
            AppStorage.setOrCreate<string>('subject', this.localObject['subject'] as string);
          } else if (fields[index] === 'emailContent') {
            AppStorage.setOrCreate<string>('emailContent', this.localObject['emailContent'] as string);
          }
        }
      }
    }

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    Logger.info('EntryAbility', 'Ability onCreate');
    this.restoringData(want, launchParam);
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    Logger.info('EntryAbility', 'Ability onNewWant');
    this.restoringData(want, launchParam);
  }

  async restoringData(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    this.checkPermissions();
    // Recovering migrated data from want.
    if (launchParam.launchReason === AbilityConstant.LaunchReason.CONTINUATION) {
      // get user data from want params.
      let sessionId: string = want.parameters?.distributedSessionId as string;
      if (!this.localObject) {
        let mailInfo: MailInfo = new MailInfo(undefined, undefined, undefined, undefined);
        this.localObject = distributedDataObject.create(this.context, mailInfo);
        this.localObject.on('change', this.changeCall);
      }
      if (sessionId && this.localObject) {
        await this.localObject.setSessionId(sessionId);
        AppStorage.setOrCreate('recipient', this.localObject['recipient']);
        AppStorage.setOrCreate('sender', this.localObject['sender']);
        AppStorage.setOrCreate('subject', this.localObject['subject']);
        AppStorage.setOrCreate('emailContent', this.localObject['emailContent']);
      }
      this.context.restoreWindowStage(new LocalStorage());
    }
  }

  onContinue(wantParam: Record<string, Object | undefined>): AbilityConstant.OnContinueResult {
    try {
      let sessionId: string = distributedDataObject.genSessionId();
      if (this.localObject) {
        this.localObject.setSessionId(sessionId);
        this.localObject['recipient'] = AppStorage.get('recipient');
        this.localObject['sender'] = AppStorage.get('sender');
        this.localObject['subject'] = AppStorage.get('subject');
        this.localObject['emailContent'] = AppStorage.get('emailContent');
        this.targetDeviceId = wantParam.targetDevice as string;
        this.localObject.save(wantParam.targetDevice as string)
        wantParam.distributedSessionId = sessionId;
      }
    } catch (error) {
      Logger.error('EntryAbility', 'distributedDataObject failed', `code ${(error as BusinessError).code}`);
    }
    return AbilityConstant.OnContinueResult.AGREE;
  }

  async checkPermissions(): Promise<void> {
    // 见上一节的代码
  }

  onWindowStageCreate(windowStage: window.WindowStage) {
    // Main window is created, set main page for this ability.
    Logger.info('EntryAbility', 'Ability onWindowStageCreate');

    windowStage.loadContent('pages/MailHomePage', (err, data) => {

      if (err.code) {
        Logger.error('EntryAbility', 'Failed to load the content, ', `Catch err: ${err}`);
        return;
      }
      Logger.info('EntryAbility', 'Succeeded in loading the content, ', `Data: ${data}`);
    });
    if (!this.localObject) {
      let mailInfo: MailInfo = new MailInfo(undefined, undefined, undefined, undefined);
      this.localObject = distributedDataObject.create(this.context, mailInfo);
    }
  }
  
  async onDestroy() {
    Logger.info('EntryAbility', 'Ability onDestroy');
    if (this.localObject && this.targetDeviceId) {
      await this.localObject.save(this.targetDeviceId).then(() => {
        Logger.info('onDestroy localObject save success');
      }).catch((err: BusinessError) => {
        Logger.error(`Failed to save. Code:${err.code},message:${err.message}`);
      });
    }
  }
}

如果同步/迁移的数据量少,直接将数据以键值对存入wantParam即可通过通用软总线同步,上例中只传递了sessionId,利用了分布式数据对象的特性让软总线自动同步:sessionId本质上是一个组网标识符。当多个设备上的分布式数据对象设置了相同的 sessionId,它们就自动加入了一个“同步组” 。这个 ID 本身不包含业务数据,它的核心作用是告诉目标设备应该去哪个“数据频道”拉取数据。真正的数据同步是由鸿蒙的分布式数据管理框架在背后自动完成的,它主要依赖以下几步:

  1. 数据持久化与推送:在源设备的 onContinue方法中,调用 this.localObject.save(wantParam.targetDevice as string)是关键 。这行代码会将当前分布式数据对象的状态持久化到分布式数据库中,并通过分布式软总线(SoftBus)自动将数据推送到目标设备​ 。这是一个系统级的、自动化的过程。
  2. 自动同步与监听:当目标设备的应用被拉起后,在 onCreate或 onNewWant中,代码通过 this.localObject.setSessionId(sessionId)加入了由 sessionId定义的同一个同步组 。一旦加入成功,分布式数据管理框架会自动将已经推送到本地的数据同步到本地的 localObject实例中。
  3. 数据恢复:同步完成后,框架会触发 'change'事件。上述代码中注册的 this.changeCall回调函数就会执行,从刚刚同步好的 this.localObject中读取数据(如 this.localObject['recipient']),并最终通过 AppStorage.setOrCreate将数据设置到应用全局状态,完成整个恢复流程 。

可以这么说,在distributedDataObject.DataObject的save方法中,实现了数据的持久化(之前我理解为数据此时上云了,其实并不是,因为它只是设备间互联),在setSessionId方法中,实现了数据的同步(其实就是通过各种近场通信,也就是华为包装的软总线,实时把数据从源设备读取出来)

大文件迁移示例:

@Entry
@Component
struct MailHomePage {

  aboutToAppear() {
    if ((this.isContinuation === CommonConstants.CAN_CONTINUATION) && (this.appendix.length >= 1)) {
      this.readFile();
    }
  }

  build() {
    // ...
  }

  /**
   * Reading file from distributed file systems.
   */
  readFile(): void {
    this.appendix.forEach((item: AppendixBean) => {
      let filePath: string = this.distributedPath + item.fileName;
      let savePath: string = getContext().filesDir + '/' + item.fileName;
      try {
        while (fileIo.accessSync(filePath)) {
          let saveFile = fileIo.openSync(savePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
          let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE);
          let buf: ArrayBuffer = new ArrayBuffer(CommonConstants.FILE_BUFFER_SIZE);
          let readSize = 0;
          let readLen = fileIo.readSync(file.fd, buf, { offset: readSize });
          while (readLen > 0) {
            readSize += readLen;
            fileIo.writeSync(saveFile.fd, buf);
            readLen = fileIo.readSync(file.fd, buf, { offset: readSize });
          }
          fileIo.closeSync(file);
          fileIo.closeSync(saveFile);
        }
      } catch (error) {
        let err: BusinessError = error as BusinessError;
        Logger.error(`DocumentViewPicker failed with err: ${JSON.stringify(err)}`);
      }
    });
  }

  /**
   * Add appendix from file manager.
   *
   * @param fileType
   */
  documentSelect(fileType: number): void {
    try {
      let DocumentSelectOptions = new picker.DocumentSelectOptions();
      let documentPicker = new picker.DocumentViewPicker();
      documentPicker.select(DocumentSelectOptions).then((DocumentSelectResult: Array<string>) => {
        for (let documentSelectResultElement of DocumentSelectResult) {
          let buf = new ArrayBuffer(CommonConstants.FILE_BUFFER_SIZE);
          let readSize = 0;
          let file = fileIo.openSync(documentSelectResultElement, fileIo.OpenMode.READ_ONLY);
          let readLen = fileIo.readSync(file.fd, buf, { offset: readSize });
          // File name is not supported chinese name.
          let fileName = file.name;
          if (!fileName.endsWith(imageIndex[fileType].fileType) ||
          new RegExp("\[\\u4E00-\\u9FA5]|[\\uFE30-\\uFFA0]", "gi").test(fileName)) {
            promptAction.showToast({
              message: $r('app.string.alert_message_chinese')
            })
            return;
          }
          let destination = fileIo.openSync(getContext()
            .filesDir + '/' + fileName, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
          let destinationDistribute = fileIo.openSync(getContext()
            .distributedFilesDir + '/' + fileName, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
          while (readLen > 0) {
            readSize += readLen;
            fileIo.writeSync(destination.fd, buf);
            fileIo.writeSync(destinationDistribute.fd, buf);
            console.info(destinationDistribute.path);
            readLen = fileIo.readSync(file.fd, buf, { offset: readSize });
          }
          fileIo.closeSync(file);
          fileIo.closeSync(destination);
          fileIo.closeSync(destinationDistribute);
          this.appendix.push({ iconIndex: fileType, fileName: fileName });
        }
        Logger.info(`DocumentViewPicker.select successfully, DocumentSelectResult uri: ${JSON.stringify(DocumentSelectResult)}`);
      }).catch((err: BusinessError) => {
        Logger.error(`DocumentViewPicker.select failed with err: ${JSON.stringify(err)}`);
      });
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      Logger.error(`DocumentViewPicker failed with err: ${JSON.stringify(err)}`);
    }
  }
}
  • 第一阶段:源设备 - 文件准备与上传 (documentSelect方法),用户通过文件选择器 (DocumentViewPicker) 选择文件后,代码以只读模式打开该文件,并分片读取文件内容到一个缓冲区 (ArrayBuffer), 这一步的核心动作是 “双写”​。
    • 应用本地目录​ (getContext().filesDir + '/' + fileName): 保证文件在当前设备本地可用。
    • 应用分布式目录​ (getContext().distributedFilesDir + '/' + fileName): 这是实现跨端访问的关键。鸿蒙系统会管理此目录下的文件,使其可被同一账号下组网的其他设备上的同名应用访问
    • 文件成功写入后,其名称等信息会被添加到 this.appendix数组中。这个数组的作用是在迁移时,告知目标设备需要同步哪些文件。
  • 第二阶段:目标设备 - 文件恢复与同步 (readFile方法)
    • 触发恢复:在目标设备上,应用启动后,在 aboutToAppear生命周期中,代码会检查 this.isContinuation和 this.appendix,确认本次启动是由于迁移且带有附件,从而调用 readFile方法。
    • 从分布式目录读取:readFile方法遍历 this.appendix数组中的文件列表。对于每个文件,它尝试从分布式目录​ (this.distributedPath + item.fileName) 中读取。此时,源设备通过分布式软总线提供的分布式文件系统(hmdfs)使得这个文件在目标设备上“可见” 。
    • 写入目标设备本地:代码将从分布式目录读取到的文件内容,写入到目标设备的应用本地目录 (getContext().filesDir + '/' + item.fileName)。这一步完成后,文件就真正“落地”到了新设备上,迁移完成

应用开发安全

  • 安全设计理念-以硬件TCB作为安全信任基础,软硬结合的安全设计
  • 安全设计理念-分布式基于B.L.& Biba分级安全模型
    • 机密性模型:Bell-Lapadula 模型
    • 完整性模型:Biba 模型
  • 系统安全-软硬件结合安全加固,防护系统被篡改或利用
  • 数据分级保护-保护用户的数据安全加密
  • 应用权限访问控制分级-“洋葱”模型划分的分级权限管控

访问控制原则

  • 以系统分配的Access Token作为应用标识符,不以UID作为标识
  • 当访问的主体应用APL大于等于客体APL时,只要在Config中声明即可允许访问
  • 当访问的主体应用APL低于客体APL时,如需使用高权限则使用必须通过Provisioning Profile的ACL中进行授权(需审核)

安全能力

  • 应用加固- 应用混淆、代码签名、应用加密和反调试
    • 提供了基础的应用加固安全能力,包括混淆、加密和代码签名能力,保护代码反编译和反调试
  • 应用完整性保护 -应用代码签名
    1. APP代码文件签名,启动APP时校验,防止篡改二进制可执行文件
    2. 代码段签名,只执行指定“签名”的代码,防止代码注入攻击
  • 应用权限管理- 权限申请和授权,上文已提过需要在config或provison文件申请。
  • 应用快速修复-热补丁需上架审核签名发布,不允许三方更新
  • TEE TA应用开发-基于TEE的开发TA,高安全应用场景
    • 为了帮助金融等高安全应用场景需求,基于TEE可信运行环境,开发者可以开发应用的TA,TEE隔离运行,外部无法访问TEE运行的TA,即使Root也无法获取或劫持TA应用,可用于应用的支付认证等场景
    • 不公开提供SDK
  • 文件加密保护 - 提供基于文件分类加密保护
  • 关键资产存储(Asset)-银行卡号、Token、口令等短数据存储加密保护
    • 针对关键敏感数据,为用户提供基于底层TEE级别系统安全保护;提供关键敏感数据管理API,开发者无需关注底层具体安全实现
  • 密钥管理(Universal Keystore)- 基于硬件TEE运行环境的密钥加解密管理
  • 设备密钥证明-基于TEE级别的设备证书的密钥公钥证明
    • 应用通过设备的Attestation机制获取可信证书,云端根据预置根证书验签证明数据可信。
  • 应用加解密引擎- 基于OpenSSL封装提供JS接口
    • 通过对OpenSSL接口封装JS接口,实现加解密算法的北向接口
  • 应用身份认证- 基于系统口令、人脸、指纹的身份认证
  • 应用身份认证-基于TEE级别安全的系统人脸、指纹生态特征认证
    • 申请权限:ohos.permission.ACCESS_BIOMETRIC权限

文件加密策略: |分级加密|策略| |---|---| el4|用户锁定设备后不久(一般为10秒钟),解密的数据保护类密钥会被从内存丢弃,此类的所有数据都无法访问,除非用户再次输入密码或使用指纹或面容解锁设备。 el3|用户锁定设备后,如果文件已经被打开,则文件始终可以被继续访问,一旦文件关闭(锁), 文件将不能被再次访问,除非用户再次输入密码或使用指纹或面容解锁设备。但对于类似锁屏期间产生的数据支持写入到临时存储区 el2|用户开机后首次解锁设备后,才可对文件进行访问。这是未分配给数据保护类的所有第三方应用数据的默认数据保护类 el1|设备在直接启动模式下和用户解锁设备后均可对文件进行访问

应用如无特殊需要,应将数据存放在el2加密目录下,以尽可能保证数据安全。(默认也是el2)

获取分级数据文件路径:

getEl2Path(): void {
  let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
  context.area = contextConstant.AreaMode.EL2;
  let filePath = context.filesDir + '/health_data.txt';
  this.message = filePath;
}

数据加解密见此

隐私保护

  • 应用使用敏感资源用户可知可控
    1. 应用前台显性提醒:应用前台使用敏感器件时,在右上角以小圆点显性提醒
    2. 应用后台使用提醒:应用后台使用敏感器件时,必须要显示的弹出悬浮窗
    3. 敏感器件全局开关:用户可以在SystemUI关闭敏感器件全局开关,应用在关闭状态使用全局开关时,系统弹框让用户确认是否打开
    4. 权限访问记录:用户可以在Setting里查看应用的权限访问记录
  • 以Picker、系统控件方式访问无需向用户申请权限
  • 支持应用设置隐私模式,实现防录屏截屏功能
  • 隐私反跟踪:统一设备唯一标识,反跟踪
    • 为了符合隐私要求,防止应用对用户的隐私追踪,设备不再对应用提供永久设备ID访问,通过统一的OAID/AAID

使用模糊定位获取位置信息 在API9以后,当应用同时申请 ohos.permission.APPROXIMATELY_LOCATIONohos.permission.LOCATION 权限时,可以获取用户精准位置,精准度达到米级。如果应用仅申请 ohos.permission.APPROXIMATELY_LOCATION 权限,权限弹框仅显示模糊位置权限。

// module.json5
{
  "module": {
    // ...
    "requestPermissions": [
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:location_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "always"
        }
      },
      // ...
    ]
  }
}

动态申请:

const accessManager = abilityAccessCtrl.createAtManager();

accessManager.requestPermissionsFromUser(this.getUIContext().getHostContext(), ['ohos.permission.APPROXIMATELY_LOCATION'])
  .then((data) => {
    let grantStatus: Array<number> = data.authResults;
    if (grantStatus.length > 0 && grantStatus[0] === 0) {
      // The user is authorized to continue to access the target operation
      Logger.info('request permissions granted');
      // ...
    } else {
      // The user rejects the authorization
      Logger.info('request permissions denied');
      // ...
    }
    Logger.info(`request permissions result: ${JSON.stringify(data)}`);
  })

获取位置信息接口

let requestInfo: geoLocationManager.LocationRequest = {
'priority': geoLocationManager.LocationRequestPriority.FIRST_FIX,
'scenario': geoLocationManager.LocationRequestScenario.UNSET,
'timeInterval': 1,
'distanceInterval': 0,
'maxAccuracy': 0
};
geoLocationManager.getCurrentLocation(requestInfo).then(result => {
Logger.info(`geoLocation current location:error: ${JSON.stringify(result)}`)
// ...
}).catch((error: BusinessError) => {
Logger.error(`geoLocation getCurrentLocation: error: ${JSON.stringify(error)}`)
});

photo picker

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import Logger from '../utils/Logger';

@Entry
@Component
struct PickerDemo {
  @State imageUri: string = '';

  build() {
    RelativeContainer() {
      Image(this.imageUri)
      // ...
      Button($r('app.string.select_picture'))
      // ...
        .onClick(() => {
          const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
          photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
          photoSelectOptions.maxSelectNumber = 5;
          const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
          photoViewPicker.select(photoSelectOptions).then(photoSelectResult => {
            this.imageUri = photoSelectResult.photoUris[0];
            Logger.info(`PhotoViewPicker.select successfully, imageUri: ${this.imageUri}`)
          }).catch((err: BusinessError) => {
            Logger.error(`PhotoViewPicker.select failed with err: ${JSON.stringify(err)}`)
          })
        })
    }
    // ...
  }
}