阅读视图

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

鸿蒙日历服务实践:把应用里的事件写进用户的日程表

引言

很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。

鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。

本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。


一、日历服务能做什么

1.1 核心能力

简单说就是三件事:

  1. 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
  2. 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
  3. 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。

写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。

1.2 一键服务按钮的显示时机

一键服务按钮不是一直显示的,不同入口的出现时机不一样:

入口 显示时机
桌面卡片 / 月视图日程列表卡片 日程开始前 15 分钟显示,日程结束后自动隐藏
日程详情页 始终显示
日程通知 通知弹出时显示,点击通知卡片后显示

这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。

1.3 9 种典型服务场景

Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:

场景 ServiceType 按钮文案
会议 Meeting 加入会议
追剧 Watching 立即观看
还款 Repayment 马上还款
直播 Live 开启直播
购物 Shopping 开始选购
出行 Trip 立即查看
上课 Class 开始上课
赛事 SportsEvents 立即观看
运动 SportsExercise 开始运动

选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。


二、开发前的准备工作

在写第一行业务代码之前,有三步准备工作需要完成:

第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。

第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDARohos.permission.WRITE_CALENDAR

第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。


三、理解日程的数据结构

在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。

3.1 日历账户

每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。

账户有三个关键属性:

  • name:账户标识,供系统内部使用。
  • type:账户类型,一般使用 LOCAL
  • displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。

3.2 日程字段

一条日程的核心字段包括:

  • title:标题,出现在日程卡片上最醒目的位置。
  • startTime / endTime:起止时间,时间戳格式。
  • isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
  • reminderTime:提醒时间,是一个数组,单位是分钟。比如 [0, 10] 表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0 表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。
  • description:备注信息,可以补充标题里放不下的细节。
  • location:地点信息,包含地址文本和经纬度。
  • service:一键服务配置,包括服务类型(type)和跳转链接(uri,DeepLink 格式)。

3.3 日程的增删改查

日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。


四、典型场景实践

下面通过两个最常见的场景——出行服务会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。

4.1 出行服务场景

这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。

字段设计思路:

标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP

创建日程:

import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';

let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;

async createTripCalendarAndEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'TripCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '高铁出行'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  const startTime = new Date('2025-10-01T08:17:00').getTime();
  const endTime = new Date('2025-10-01T12:51:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '行程信息:G107 上海虹桥-北京南',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [120, 240],
    description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
    service: {
      type: calendarManager.ServiceType.TRIP,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!tripCalendar) {
      console.error('Failed to create calendar.');
      return;
    }
    await tripCalendar.setConfig(config);
    id = await tripCalendar.addEvent(event);
    oriEvent = event;
    oriEvent.id = id;
    console.info(`日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。

日程的后续管理:

出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:

async updateTripEvent(): Promise<void> {
  if (!tripCalendar || !oriEvent) return;
  
  // 改签后更新起止时间
  oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
  oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();

  try {
    await tripCalendar.updateEvent(oriEvent);
    console.info('日程更新成功');
  } catch (err) {
    console.error(`更新失败: ${err.code}, ${err.message}`);
  }
}

如果用户退票了,则直接删除日程:

async deleteTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    await tripCalendar.deleteEvent(id);
    oriEvent = null;
    console.info('日程已删除');
  } catch (err) {
    console.error(`删除失败: ${err.code}, ${err.message}`);
  }
}

需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:

async getTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    const filter = calendarManager.EventFilter.filterById([id]);
    let data = await tripCalendar.getEvents(filter, 
      ['title', 'type', 'startTime', 'endTime']);
    if (data && data.length > 0) {
      oriEvent = data[0];
    }
  } catch (err) {
    console.error(`查询失败: ${err.code}, ${err.message}`);
  }
}

4.2 会议场景

会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。

字段设计思路:

标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。

async createMeetingEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'meetingCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '会议'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  let attendee: calendarManager.Attendee[] = [
    {
      name: 'Alice',
      email: 'alice@example.com',
      role: calendarManager.AttendeeRole.ORGANIZER
    },
    {
      name: 'Jack',
      email: 'jack@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    },
    {
      name: 'Jerry',
      email: 'jerry@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    }
  ];

  const startTime = new Date('2025-10-20T09:00:00').getTime();
  const endTime = new Date('2025-10-20T10:00:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '产品方案评审会议',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [0, 15],
    attendee: attendee,
    description: 'Q4产品方案评审',
    service: {
      type: calendarManager.ServiceType.MEETING,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    let calendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!calendar) return;
    await calendar.setConfig(config);
    id = await calendar.addEvent(event);
    console.info(`会议日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。


五、其他场景速览

前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:

酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。

还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。

直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。


六、总结与实践建议

日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:

  1. 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
  2. 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
  3. 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
  4. displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
  5. DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。

日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。

鸿蒙碰一碰分享:手机轻碰,内容就过去了

引言

跨设备传内容这件事,理想状态是什么?大概就是——我手机上有个东西想给你,碰一下就过去了,不用加好友、不用扫码、不用等配对。

鸿蒙 Share Kit 的碰一碰分享做的就是这件事。两台手机轻碰一下,图片、链接、Wi-Fi 信息就传过去了;手机往 PC 屏幕上一放,文件就到了电脑里。整个过程没有中间步骤,靠的是设备间物理接触触发的分享机制。

本文面向希望为应用接入碰一碰分享能力的鸿蒙开发者,从手机间分享和手机与 PC/2in1 间分享两个维度,梳理这项能力的工作机制、卡片设计要点、异常处理策略,以及完整的开发流程。


一、手机与手机之间的碰一碰分享

1.1 基本流程

碰一碰分享的业务流程可以用四步概括:

  1. 注册:应用在可分享的页面注册碰一碰事件(knockShare)。
  2. 触发:用户将手机与对端设备轻碰,系统发现设备后触发回调。
  3. 发送:应用在回调中构造分享数据并发送。
  4. 清理:离开可分享页面时,解除事件注册。

使用前有几个前提条件:双端设备都要亮屏且解锁,华为分享服务需要处于开启状态(系统默认开启)。如果用户手动关闭了华为分享服务,轻碰时会收到系统通知提示开启。

还有一点需要了解:宿主应用无法直接获知分享结果。对端是接收了还是拒绝了,Share Kit 会通过系统通知告知用户,而不是通过回调返回给应用。如果任意一端设备不支持碰一碰能力,轻碰则完全没有响应。

环境要求方面,手机系统需要 HarmonyOS NEXT Release 及以上版本。可以用 canIUse 做运行时判断:

if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
  // 支持碰一碰分享
}

1.2 设备间的信任与安全

从 HarmonyOS NEXT 5.0.0.123 SP16 开始,碰一碰分享在发送端和接收端都会展示对方的身份信息,帮助用户确认"我在和谁传东西":

  • 如果对端已登录华为账号,会展示对方的账号昵称和头像
  • 如果对端未登录华为账号,则展示设备信息

需要注意的是,如果发送端的系统版本低于 SP16,接收端将不会展示任何发送方信息。


二、分享卡片的设计:不只是技术问题

碰一碰触发后,对端设备会收到一张分享卡片。卡片的样式直接影响用户是否愿意接收,所以这部分值得认真对待。

2.1 三种卡片模板

Share Kit 根据你传入的字段组合,自动匹配不同的卡片模板:

纯图片布局——只有预览图,没有标题和描述。适合分享文件、图片等不需要文字说明的场景。构造分享数据时只传 thumbnailUri 即可触发这种布局。预览图支持最小宽高比 1:4,超出部分会被裁剪。

沉浸式大卡布局——预览图 + 标题 + 描述 + 应用图标,视觉冲击力最强。适合分享链接类内容。触发条件是同时传入 titledescriptionthumbnailUri,且预览图宽高比小于 1:1(即竖图)。标题最多显示 2 行,描述 1 行,超出部分以省略号截断。如果标题末尾有重要信息,建议控制在 20 个中文字符左右。

白卡上下布局——同样包含预览图、标题、描述和应用图标,但预览图只显示在卡片上方,不会铺满整张卡片。触发条件和沉浸式大卡一样,区别在于预览图宽高比大于 1:1(即横图)。

应用图标不需要额外配置,系统会自动获取。

2.2 预览图的质量建议

预览图太大会拖慢加载速度,太小则显示模糊。建议参考以下标准:

预览图来源 推荐比例 推荐分辨率
应用创作的海报 3:4 最小 600×800,最大 3000×4000
用户上传的图片 不限制 最大 3000×4000

2.3 预览图来不及下载怎么办

一个很实际的问题:如果应用使用的是云端存储的图片作为预览图,碰一碰回调触发时图片可能还没下载到本地,这就会导致超时失败。

Share Kit 对此提供了预览图延迟更新的能力。思路很简单——先发核心数据,建立连接,系统会用默认预览图填充卡片;等云端图片下载完成后,再调用 sharableTarget.updateShareData 更新预览图:

harmonyShare.on('knockShare', capabilityRegistry, (sharableTarget) => {
  // 先发送核心数据,不带预览图
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);

  // 图片下载完成后更新预览图
  setTimeout(() => {
    let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
    sharableTarget.updateShareData({
      thumbnailUri: fileUri.getUriFromPath(filePath)
    });
  }, 5000);
});

这样用户不会因为预览图加载慢而等待,分享体验更流畅。


三、用户引导:让用户知道"这里可以碰"

碰一碰是一个相对新的交互方式,很多用户可能不知道当前页面支持这个功能。给出适当的引导可以有效提升分享意愿。

Share Kit 推荐两种引导方式:

  • 文本提示:在页面上展示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"的文案。
  • 动图提示:用动画展示碰一碰的操作方式,更直观。

Share Kit 提供了统一的动图资源文件。下载后将 knock_share_guide 目录下的所有文件放到应用的 entry/src/main/resources/rawfile 目录即可使用。


四、核心开发流程:注册、发送、清理

4.1 注册与取消碰一碰事件

注册碰一碰事件有两种方式。简单场景下,直接传入回调函数即可:

private immersiveListening() {
  harmonyShare.on('knockShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('knockShare', this.immersiveCallback);
}

如果需要更精细的控制(比如指定窗口、声明单向发送能力),可以传入 SendCapabilityRegistry 配置:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,  // 替换为实际的 windowId
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

和隔空传送一样,生命周期管理是关键。进入可分享页面时注册,离开时(包括退后台)必须取消:

aboutToAppear(): void {
  this.immersiveListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  this.immersiveDisablingListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

onPageHide(): void {
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

4.2 构造分享数据并发送

在碰一碰回调中构造分享数据。链接类分享的 utd 类型需要设置为 HYPERLINK

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);
}

4.3 通过 App Linking 实现直达应用

分享链接时,强烈建议使用 App Linking 而不是普通 URL。App Linking 的好处在于:

  • 应用已安装:直接拉起应用对应页面。
  • 应用未安装:默认通过浏览器打开网页;配合 App Linking Kit 的直达应用市场能力,可以直接跳转到应用市场引导安装。再结合延迟链接能力,用户安装完成后首次打开应用时,仍能获取之前分享的链接内容——这对转化率的提升非常有价值。

另一种方案是 Deep Linking,但它只在本地已安装的应用中查找匹配项,未安装时会提示"暂无可用打开方式"。

4.4 异常场景的处理

碰一碰触发后,并不总是能顺利完成分享。Share Kit 提供了两种异常处理方式,帮助开发者优雅地终止分享,避免用户干等:

当前界面无可分享内容(6.0.2(22) 版本起支持):

sharableTarget.clarifyNonShare({ 
  message: '请在支持碰一碰分享的界面再试' 
});

这会终止本次分享,并弹出提示引导用户去可分享的页面。

网络或业务原因导致分享失败(5.0.3(15) 版本起支持):

sharableTarget.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR);

这会终止分享并提示用户具体原因。


五、邀请组队:碰一碰的另一种玩法

除了内容分享,碰一碰还有一个很有意思的应用场景——邀请组队。比如游戏中邀请旁边的朋友加入房间,碰一下手机就完成了。

这个场景有一个特殊问题:如果双方都在组队房间里互碰,会导致互相邀请加入对方房间的冲突。Share Kit 对此提供了单向仅发送能力,通过在注册时设置 sendOnly: true 来声明:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,
  sendOnly: true,  // 声明仅支持单向发送
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

当碰一碰的双方都设置了 sendOnly,系统会终止本次分享并提示"请任意一方退出当前应用后再试"。只要有一方没设置 sendOnly,分享就能正常完成。

对端应用被拉起后,通过 onCreateonNewWant 回调中的 want.uri 获取组队链接,解析其中的参数来处理组队逻辑:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 解析链接参数,处理组队邀请
}

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 应用已在前台时的处理
}

六、手机与 PC/2in1 之间的碰一碰分享

碰一碰不只是手机之间的事。手机和 PC/2in1 设备之间也可以碰一碰分享,而且交互方式更有趣——手机直接往屏幕上一放,利用 PC/2in1 的屏幕感知能力识别碰触动作和位置,实现窗口级的精准交互。

6.1 谁发谁收的规则

从 6.0.0(20) Beta5 版本开始,手机与 PC/2in1 之间不支持双向分享,遵循明确的优先级:

  1. 手机前台有可分享内容 → 手机发送,PC/2in1 接收。
  2. 手机前台无可分享内容,PC/2in1 前台窗口有 → PC/2in1 发送,手机接收。
  3. 双方前台都没有可分享内容 → 走无内容分享逻辑。

更早的版本(6.0.0(20) Beta3 及之前)支持双向同时分享,但后续版本取消了这个行为。

6.2 物理姿态要求

手机碰 PC/2in1 屏幕时,对放置姿态有具体要求:

  • 俯视夹角 ≤ 5°(手机要基本平放在屏幕上)
  • 侧视夹角 > 35°
  • 正视夹角 ≤ 25°
  • 手机不能超出屏幕边缘

此外,支持官方手机保护壳,但过厚的外壳可能影响感知。仅支持直板手机或折叠手机的直板形态。双端设备需要登录相同的华为账号。

6.3 沙箱接收:文件直达应用

从 6.0.0(20) 版本开始,PC/2in1 设备支持沙箱接收能力——手机碰一下屏幕,文件直接传入 PC/2in1 应用的沙箱目录,传完后通知应用处理,无需用户手动操作。

应用需要声明自己支持接收的文件类型和最大数量。如果类型不匹配,系统会回退到华为分享的默认接收逻辑;如果数量不匹配,会弹窗提示用户。

注册沙箱接收事件:

aboutToAppear(): void {
  let capabilityRegistry: harmonyShare.RecvCapabilityRegistry = {
    windowId: 999,
    capabilities: [{
      utd: utd.UniformDataType.IMAGE,
      maxSupportedCount: 1,
    }]
  };

  harmonyShare.on('dataReceive', capabilityRegistry, 
    (receivableTarget: harmonyShare.ReceivableTarget) => {
      let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      receivableTarget.receive(context.filesDir, {
        onDataReceived: (sharedData: systemShare.SharedData) => {
          let records = sharedData.getRecords();
          records.forEach((record) => {
            // 处理接收到的文件
          });
        },
        onResult(resultCode: harmonyShare.ShareResultCode) {
          if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
            // 接收成功
          }
        }
      });
    }
  );
}

离开页面时同样要解除注册。如果因为业务原因需要拒绝本次接收,可以调用 receivableTarget.reject()


七、总结

碰一碰分享覆盖了两大场景:手机与手机之间的内容传输和组队邀请,以及手机与 PC/2in1 之间的文件传输。对开发者来说,接入时有几个核心关注点:

  1. 生命周期管理是基本功。注册和取消事件必须与页面生命周期严格对应,退后台也要取消,否则会出现意料之外的行为。

  2. 卡片设计影响转化。三种卡片模板由字段组合和图片比例自动决定,了解触发规则后有意识地选择合适的布局,预览图质量要控制在合理范围内。

  3. 异常处理不能省。无内容可分享时用 clarifyNonShare 引导用户,网络异常时用 reject 终止等待。这些细节决定了用户在非理想状态下的体验。

  4. App Linking 是最佳搭档。结合延迟链接和直达应用市场能力,即使对端没有安装应用,也能完成从分享到安装到打开内容的完整链路。

  5. 手机碰 PC/2in1 的姿态要求值得在产品引导中告知用户,避免"碰了没反应"的困惑。沙箱接收能力则为 PC 端应用打开了一种高效的文件接收方式。

鸿蒙隔空传送:一抓一放,内容就到了对面的设备上

引言

跨设备传文件这件事,我们已经做了太多年了——蓝牙配对、扫码互传、聊天窗口转发、甚至给自己发邮件。这些方式都能用,但都不够"顺手"。

鸿蒙 Share Kit 新推出的隔空传送,尝试用一种更自然的交互来解决这个问题:用户对着屏幕做一个"抓取"手势,内容就被"拿"起来了,再对着另一台设备"放下",内容就传过去了。整个过程不需要打开任何传输工具,也不需要手动选择接收设备。

本文面向希望在应用中接入隔空传送能力的鸿蒙开发者,梳理这项功能的工作机制、与系统其他功能的联动关系,以及具体的接入方法。


一、隔空传送是怎么工作的

1.1 基本交互逻辑

隔空传送的核心交互是"一抓一放"——用户在一台设备前做出握拳抓取的手势,设备捕捉到这个动作后,将当前页面的分享内容"抓起来";然后用户面向另一台设备做出释放手势,内容就传送到了对端设备上。

这个手势并不是凭空工作的。它依赖应用侧主动注册分享事件——只有当前页面注册了隔空传送的监听,系统才知道"这个页面有东西可以分享"。如果页面没注册,手势不会触发隔空传送。

1.2 使用前提:打开隔空传送开关

隔空传送默认不是开启状态,用户需要手动打开:

设置 → 系统 → 快捷启动和手势 → 隔空传送

这是设备级的开关,对所有支持隔空传送的应用生效。

1.3 设备信任机制

传输的安全性通过设备信任关系来保障,分两种情况:

  • 同账号设备:如果两台设备登录了相同的华为账号,系统默认它们互相信任,传输时无需额外确认,直接发送。
  • 不同账号设备:需要双端用户各自确认"信任对方设备"。确认后,1 小时内再次传输无需重复确认。超过 1 小时则需要重新建立信任。

这种设计在便捷性和安全性之间取了一个平衡——自己的设备间传东西零障碍,借别人设备传也不会被滥用。


二、隔空传送与隔空截屏的关系

一个容易让人困惑的地方是:隔空传送和隔空截屏共用同一个手势触发。这意味着用户做"抓取"动作时,可能同时触发两件事。系统通过两个开关的组合状态来决定具体行为:

隔空传送开启 隔空传送关闭
隔空截屏开启 图库场景传输原图;其他场景传送截屏 仅截屏,不传送
隔空截屏关闭 图库场景传送原图;其他场景无截屏也不传送 什么都不发生

几个值得注意的细节:

  • 当两个开关都打开,且当前页面注册了隔空传送事件时,抓取手势会同时触发隔空传送和隔空截屏。此时隔空传送的卡片下方会出现"保存截屏至本机"的提示。
  • 首次触发时,默认不保存截屏。用户可以手动勾选保存,系统会记住这个选择,作为下次的默认值。
  • 如果只开了隔空截屏、没开隔空传送,那抓取手势就只是截屏,不会有任何传输行为。

对于开发者来说,不需要关心截屏逻辑——这完全是系统层面的行为。你只需要关注隔空传送的注册和数据准备。


三、接入隔空传送的开发实践

接入隔空传送的核心工作就是三件事:在对的时机注册监听、在回调中准备分享数据、在离开时取消监听

3.1 导入所需模块

import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare, harmonyShare } from '@kit.ShareKit';
import { fileUri } from '@kit.CoreFileKit';

这里涉及三个 Kit:ShareKit 提供隔空传送和分享的核心能力,ArkData 中的 uniformTypeDescriptor 用于声明分享内容的数据类型,CoreFileKit 用于将文件路径转换为 URI。

3.2 定义手势触发时的分享逻辑

当用户做出抓取手势时,系统会通过注册的回调把一个 SharableTarget 对象传给你。你需要在这个回调里准备好分享数据,然后调用 sharableTarget.share() 把数据发出去。

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let uiContext: UIContext = this.getUIContext();
  let contextFaker: Context = uiContext.getHostContext() as Context;
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';

  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '隔空传送分享卡片标题',
    description: '隔空传送分享卡片描述'
  });

  sharableTarget.share(shareData);
}

这里有一个关键的时间约束:收到回调后,建议在 3 秒内调用 sharableTarget.share(),否则可能因为超时导致传输失败。所以分享数据的准备不宜太重——如果需要读取大文件或做复杂处理,建议提前准备好,在回调中直接使用。

分享数据通过 SharedData 构造,几个关键字段的含义:

  • utd:声明分享内容的统一数据类型。上面的示例使用的是 HYPERLINK,表示分享一个链接。
  • content:实际的分享内容,这里是一个 App Linking 链接。
  • thumbnailUri:传送卡片上显示的缩略图,需要传入文件的 URI。
  • titledescription:传送卡片上的标题和描述文字。

3.3 分享 App Linking 实现直达应用

上面的示例中,content 字段传入的是一个 App Linking 链接。这样做的好处是:对端设备收到后,点击卡片可以直接跳转到对应的应用页面,而不只是打开一个网页。如果你希望实现这种"传送即直达"的体验,应用需要先接入 App Linking。

3.4 在正确的时机注册和取消监听

这一步直接决定了功能能否正常工作。原则很简单:

  • 进入可分享页面时注册,告诉系统"这个页面有内容可以分享"。
  • 离开可分享页面时取消,包括页面销毁和应用退到后台的场景。

注册和取消的方法很直接:

private immersiveListening() {
  harmonyShare.on('gesturesShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('gesturesShare', this.immersiveCallback);
}

但"什么时候算离开"需要仔细处理。除了页面销毁(aboutToDisappear),应用退到后台也应该取消监听。一个完整的处理方式如下:

aboutToAppear(): void {
  // 页面出现时注册监听
  this.immersiveListening();

  // 同时监听应用退后台事件
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  // 页面销毁时取消监听
  this.immersiveDisablingListening();

  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

// 页面隐藏时(包括退后台),通过事件总线通知
onPageHide(): void {
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

这里的思路是:通过 onPageHide 捕捉页面隐藏事件(退后台会触发),然后通过 eventHub 发出通知,在通知处理中取消隔空传送监听。这样无论是页面销毁还是应用退后台,监听都会被正确清理。

为什么要这么认真地处理取消监听? 因为如果应用退到后台后监听还在,用户在其他应用中做抓取手势时,可能会意外触发你的应用的分享逻辑,导致不可预期的行为。


四、总结

隔空传送提供了一种非常直觉化的跨设备内容传输方式。从开发者的角度看,它的接入并不复杂,但有几个关键点需要把握好:

  1. 时效性:收到手势回调后 3 秒内必须完成分享调用。分享数据要提前准备,不要在回调中做耗时操作。
  2. 生命周期管理:注册和取消监听必须与页面的生命周期严格对应。特别是应用退后台的场景,容易被忽略。
  3. 与隔空截屏的共存:理解两个开关的组合行为,在产品设计上给用户清晰的预期。
  4. 信任机制:同账号设备间传输是无感的,不同账号需要双向确认。如果你的应用场景经常涉及跨账号传输,可以在引导中提示用户。

如果你的应用有内容分享的需求——无论是链接、图片还是文件——隔空传送都是一种值得接入的自然交互方式。结合 App Linking,还能实现"传送即打开对应页面"的完整体验,这在跨设备协作场景中会非常实用。

鸿蒙文件预览开发实践:从打开文件到加速感知

引言

在很多应用场景中,用户需要快速查看一个文件的内容——可能是一份文档、一张图片、一段视频,或者一个纯文本文件。用户的预期很简单:点一下就能看,不需要专门打开另一个编辑器。

鸿蒙的 Preview Kit 提供的就是这种能力。它以拉起新窗口的方式展示文件内容,界面统一、使用简单,开发者不需要自己去实现各类文件格式的渲染逻辑。此外,从 5.0.5(17) 版本开始,Preview Kit 还引入了文件预加载状态感知的能力,让浏览器等下载类应用可以提前感知文件是否已被系统预加载,从而给用户更明确的"加速打开"提示。

本文面向有一定鸿蒙开发基础的开发者,梳理 Preview Kit 的使用方式、开发流程中的关键环节,以及文件预加载状态感知的接入方法。


一、Preview Kit 能做什么,不能做什么

在动手之前,先厘清 Preview Kit 当前的能力边界,避免走弯路。

能做的事:

  • 拉起一个独立的预览窗口,以统一的界面展示文件内容。
  • 支持图片、视频、音频、文本、HTML 等常见文件类型的预览。
  • Office 类文档(如 .docx、.xlsx 等)借助 WPS 的能力来渲染,所以预览界面中会出现"WPS 提供技术支持"等字样——这是正常的。
  • 支持单文件预览和多文件预览(多文件仅限移动端)。
  • 预览窗口是单例的,同一时间只会存在一个。

当前的限制:

  • 只支持跳出应用预览,暂不支持在应用内嵌入预览视图。如果你想在应用页面里直接内嵌一个文件预览区域,目前 Preview Kit 还做不到。
  • 没有安全定制能力。禁止截录屏、屏蔽"使用其他应用打开"、屏蔽分享入口等安全相关的控制,暂时都不支持。
  • 需要文件的访问权限。调用方必须具备对应 URI 的转授权能力,让预览窗口能正常读取文件内容。这一点在实际开发中容易踩坑,如果预览时报错,优先检查权限问题。

二、文件预览的完整开发流程

Preview Kit 的使用流程可以归纳为:判断能否预览 → 打开预览 → (可选)切换文件 → (可选)关闭预览。下面逐步展开。

2.1 导入模块

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

filePreview 是 Preview Kit 的核心模块,BusinessError 用于错误处理。

2.2 先判断文件能不能预览

在正式打开预览之前,最好先检查一下目标文件是否支持预览。这一步不是必须的,但可以帮你在 UI 层做更好的引导——比如对不支持预览的文件,显示"不支持预览"的提示而不是让用户点了没反应。

let uri = 'file://docs/storage/Users/currentUser/Documents/1.txt';
let uiContext = this.getUIContext().getHostContext() as Context;

filePreview.canPreview(uiContext, uri).then((result) => {
  console.info(`是否可预览: ${result}`);
}).catch((err: BusinessError) => {
  console.error(`判断失败, err.code = ${err.code}, err.message = ${err.message}`);
});

当传入的文件类型在支持范围内(图片、视频、音频、文本、HTML)且文件确实存在时,返回 true;否则返回 false

2.3 打开预览窗口

这是最核心的一步。Preview Kit 提供了两种异步调用风格——Promise 和 Callback,功能完全一样,选择你习惯的即可。

Promise 方式:

let uiContext = this.getUIContext().getHostContext() as Context;

let displayInfo: filePreview.DisplayInfo = {
  x: 100, y: 100,
  width: 800, height: 800
};

let fileInfo: filePreview.PreviewInfo = {
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
};

filePreview.openPreview(uiContext, fileInfo, displayInfo).then(() => {
  console.info('预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这里有几个关键参数需要说明:

  • PreviewInfo:描述要预览的文件,包含标题(title)、文件路径(uri)和 MIME 类型(mimeType)。MIME 类型告诉系统这是什么格式的文件,以便选择正确的渲染方式。
  • DisplayInfo:控制预览窗口的位置和大小,包括 x/y 坐标以及宽高。

有一个细节值得注意:1 秒内重复调用 openPreview 是无效的。这是为了防止用户快速连续点击导致重复打开窗口,开发者不需要自己加防抖逻辑。

Callback 方式:

filePreview.openPreview(uiContext, fileInfo, displayInfo, (err) => {
  if (err && err.code) {
    console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
    return;
  }
  console.info('预览打开成功');
});

2.4 一次预览多个文件(仅移动端)

在移动端场景下,你可以一次传入多个文件,用户可以在预览窗口中左右滑动切换。传入时通过 index 参数指定默认展示第几个文件(从 0 开始):

let files: Array<filePreview.PreviewInfo> = [];

files.push({
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
});

files.push({
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
});

filePreview.openPreview(uiContext, files, 0).then(() => {
  console.info('多文件预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这个能力比较适合文件管理器、聊天应用中查看多张图片等场景。

2.5 在已有窗口中切换文件

如果预览窗口已经打开了,你不需要关闭再重新打开来展示另一个文件。loadData 方法可以直接在当前窗口中加载新的文件内容:

let newFile: filePreview.PreviewInfo = {
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
};

filePreview.loadData(uiContext, newFile).then(() => {
  console.info('文件加载成功');
}).catch((err: BusinessError) => {
  console.error(`加载失败, err.code = ${err.code}, err.message = ${err.message}`);
});

需要注意的是,loadData 只在预览窗口已经存在时才生效,而且 100 毫秒内重复调用无效(比 openPreview 的 1 秒限制更短,因为切换文件的操作相对轻量)。如果传入的文件不支持预览,窗口会显示"不支持预览"的界面,而不是报错。

2.6 关闭预览窗口

filePreview.closePreview(uiContext).then(() => {
  console.info('预览窗口已关闭');
}).catch((err: BusinessError) => {
  console.error(`关闭失败, err.code = ${err.code}, err.message = ${err.message}`);
});

同样只在预览窗口存在时生效。另外,你还可以通过 hasDisplayed 方法来判断预览窗口当前是否处于打开状态,在一些需要条件判断的逻辑中会比较有用。


三、文件预加载状态感知:让"秒开"看得见

从 5.0.5(17) 版本开始,Preview Kit 新增了一项面向特定场景的能力——文件预加载状态感知。这个功能目前仅在 2in1 设备上支持,典型的使用者是浏览器等支持下载文件的应用。

3.1 这项能力解决什么问题

系统有时会对用户可能打开的文件进行预加载,也就是提前把文件内容读入缓存,让后续打开更快。但问题在于,用户并不知道哪些文件已经被预加载了。如果应用能感知到某个文件的预加载状态,就可以在 UI 上给用户一个明确的提示——比如一个"闪电"图标表示"这个文件可以秒开",体验上会好很多。

3.2 三种预加载状态

一个文件的预加载状态有三种:

状态 含义 建议的 UI 处理
PRELOADING 正在预加载中 可以显示加载动画
PRELOADED 预加载已完成 提示用户"加速打开"已就绪
NOT_PRELOADED 没有预加载 不需要额外提示

3.3 开发流程

整个接入过程分为四步:注册回调 → 添加文件监听 → 响应状态变化 → 清理资源。

第一步,导入模块并注册回调:

import { openFileBoost } from '@kit.PreviewKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

function callback(info: openFileBoost.FilePreloadStatusInfo): void {
  if (info.state === openFileBoost.FilePreloadState.PRELOADING) {
    hilog.info(0x0000, 'testTag', '文件正在预加载,可以展示加载动画');
  }
  if (info.state === openFileBoost.FilePreloadState.PRELOADED) {
    hilog.info(0x0000, 'testTag', '预加载完成,可以提示用户加速打开');
  }
  if (info.state === openFileBoost.FilePreloadState.NOT_PRELOADED) {
    hilog.info(0x0000, 'testTag', '未预加载,无需额外提示');
  }
}

// 注册监听
openFileBoost.on('filePreloadStateChanged', callback);

第二步,添加需要监听的文件:

注册回调之后,通过 addFile 传入文件的沙箱路径。之后这个文件的预加载状态一旦变化,就会通过上面注册的回调通知你。

const file = "/storage/Users/currentUser/Desktop/10MB_file.docx";
openFileBoost.addFile(file);

一个典型场景是:用户通过浏览器下载了一个文件,下载完成后,应用将该文件路径注册进来,后续如果系统对其进行了预加载,应用就能及时在下载列表中标记出来。

这里有一个限制需要注意:单个应用最多同时监听 50 个文件。不再需要关注的文件,应该及时调用 removeFile 取消监听,把名额腾出来:

openFileBoost.removeFile(file);

第三步,主动查询预加载状态:

除了被动等回调,你也可以主动查询某个文件当前的预加载状态。这在应用刚启动时特别有用——可以遍历一遍关心的文件,把已经预加载完成的标记出来:

let statusInfo = openFileBoost.queryFilePreloadStatusInfo(file);
hilog.info(0x0000, 'testTag',
  `文件: ${statusInfo.sandboxPath}, 进度: ${statusInfo.progress}, 状态: ${statusInfo.state}`);

返回的 FilePreloadStatusInfo 不仅包含状态,还有加载进度信息,可以用于更细粒度的 UI 展示。

第四步,清理资源:

当不再需要监听任何文件的预加载状态时,注销回调。off 方法支持两种用法:传入具体的回调函数则只取消该函数的监听,不传参数则取消当前进程的所有回调。

// 只取消某一个回调
openFileBoost.off('filePreloadStateChanged', callback);

// 取消所有回调
openFileBoost.off('filePreloadStateChanged');

3.4 接入前的准备

因为这项能力目前仅在 2in1 设备上可用,在接入之前,建议先通过 Syscap 查询目标设备是否支持 SystemCapability.PCService.OpenFileBoost。对于不支持的设备类型(如手机、平板),不需要也不应该调用这些接口。

另外,当前支持预加载的文件类型是有限的,不在支持范围内的文件类型默认为"未预加载"状态,不需要额外注册监听。


四、总结与实践建议

Preview Kit 的两项能力——文件预览和预加载状态感知——分别解决的是"怎么看文件"和"怎么更快地看文件"的问题。

对于文件预览,开发者要抓住几个要点:

  1. 权限先行。预览的前提是文件可被访问,URI 的转授权是最常见的问题来源。
  2. 善用 canPreview 做前置判断。在 UI 层提前给用户预期,好过让用户点击后看到失败。
  3. 预览窗口是单例的。不需要担心重复打开的问题,但切换文件时记得用 loadData 而不是重新调用 openPreview
  4. 留意调用频率限制openPreview 有 1 秒的防重复机制,loadData 是 100 毫秒。

对于预加载状态感知,如果你的应用运行在 2in1 设备上且涉及文件下载或管理,这是一个值得接入的体验增强点。用户能直观看到哪些文件可以"秒开",点击的信心和满意度都会更高。接入时注意控制监听文件数量(上限 50 个),及时清理不再需要的监听,保持资源使用的整洁。

鸿蒙跨设备互通:让你的应用"借用"另一台设备的相机和图库

引言

想象一个场景:你正在平板上编辑一份文档,需要插入一张纸质资料的照片。平板没有后置摄像头,或者拍照体验远不如手机——这时候如果能直接调起旁边那台手机的相机,拍完照片自动传回平板,那该多省事。

这就是鸿蒙跨设备互通要解决的问题。它提供了一种能力,让一台设备可以调用同账号下另一台设备的相机、扫描和图库功能,拍照、扫文档、选图片视频,都不用再手动传来传去。

本文基于鸿蒙跨设备互通的开发接口,梳理这项能力的适用范围、使用条件,以及如何在应用中完成一次完整的跨设备调用。适合对鸿蒙开发有一定基础、希望为应用接入多设备协同能力的开发者阅读。


一、哪些设备能互通,谁能调用谁

跨设备互通并不是任意两台鸿蒙设备之间都能随便调用的,它有一套明确的设备关系和调用方向。

1.1 基础调用关系

最初的设计思路很直观——屏幕更大、但拍照能力可能不够好的设备,去调用拍照更强的设备:

  • 2in1 设备可以调用平板和手机
  • 平板可以调用手机

也就是说,调用方向大致是"大屏调小屏"。

1.2 API 6.1.0(23) 之后的扩展

从 API 6.1.0(23) 开始,调用关系变得更加灵活。手机、平板和 2in1 设备都可以作为发起方,去调用远端设备的能力,但远端设备能提供什么能力,取决于设备类型:

远端设备类型 可被调用的能力
手机 拍照、扫描、图库(图片与视频)
平板 拍照、扫描、图库(图片与视频)
2in1 设备 图库(图片与视频)

简单来说,手机和平板是"全能选手",三项能力都能被远端调用;2in1 设备因为自身定位,只对外开放图库能力。

1.3 必须满足的前提条件

要让跨设备互通正常工作,以下条件缺一不可:

  • 系统版本:双端设备都需要运行 HarmonyOS NEXT 及以上版本。
  • 同一账号:双端设备必须登录同一个华为账号。
  • 网络和蓝牙:双端设备都需要打开 WLAN 和蓝牙开关。如果条件允许,接入同一个局域网可以明显提升唤醒远端相机的速度。

如果在调用视频选择器时遇到资源加载异常,建议先确认双端的设备调用能力是否匹配、系统状态是否正常,然后在稳定的网络环境下重试。


二、核心流程:从发现设备到接收数据

跨设备互通的开发流程可以拆解为三个环节:发现可用设备 → 发起调用 → 接收回传数据。鸿蒙为这三个环节提供了对应的组件支持。

2.1 发现设备:设备列表选择器

第一步是让用户看到周围有哪些设备可以调用。这通过 createCollaborationServiceMenuItems 来实现——它是一个自定义构建函数,作用是在菜单中展示当前组网内具备对应互通能力的设备列表。

使用时需要传入一个能力过滤参数(CollaborationServiceFilter),告诉它你需要哪种能力的设备。目前支持的过滤值包括:

过滤值 含义
ALL 匹配拍照、扫描和图库选择器(预留值,后续会拓展)
TAKE_PHOTO 匹配跨设备拍照能力
SCAN_DOCUMENT 匹配跨设备扫描能力
IMAGE_PICKER 匹配跨设备图库(图片)能力
VIDEO_PICKER 匹配视频选择器
IMAGE_VIDEO_PICKER 匹配图片和视频选择器

有一点需要注意:这个方法必须在 Menu 组件内调用,因为它本质上是往菜单里填充设备列表项。同时它是一个 @Builder 自定义构建函数,如果你对 @Builder 的用法还不太熟悉,建议先了解一下相关文档。

2.2 状态提示与数据回传:弹窗组件

当用户选择了一台远端设备并触发操作后(比如在远端手机上拍照),你需要一个组件来做两件事:一是提示用户远端设备当前的操作状态,二是在操作完成后接收回传的数据。

CollaborationServiceStateDialog 就是做这个的。它是一个全局弹窗组件,放在页面的 build 方法中即可,不会影响原有的页面布局。它最关键的部分是 onState 回调方法,这个回调会在远端操作完成时被触发,带回三个参数:

  • stateCode:操作完成状态,0 表示成功
  • bufferType:回传数据的类型(比如 "general.image" 表示图片)
  • buffer:回传的数据内容本身(ArrayBuffer 格式)

开发者只需要在 onState 里根据这些参数,结合自己的业务逻辑处理回传数据即可。


三、完整开发实践:调用远端相机拍照并显示

理解了上面的核心流程后,我们来看一个完整的实现。这个示例的目标很简单:点击按钮弹出可用设备列表,选择一台远端设备拍照,拍完后把照片显示在本端页面上。

3.1 导入所需模块

import {
  createCollaborationServiceMenuItems,
  CollaborationServiceStateDialog,
  CollaborationServiceFilter
} from '@kit.ServiceCollaborationKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

这里引入了三个 Kit:ServiceCollaborationKit 提供跨设备互通的核心能力,ImageKit 用于处理回传的图片数据,PerformanceAnalysisKit 用于日志输出。

3.2 搭建页面结构

@Entry
@Component
struct Index {
  @State picture: PixelMap | undefined = undefined;

  @Builder
  MyTestMenu() {
    Menu() {
      createCollaborationServiceMenuItems([CollaborationServiceFilter.ALL])
    }
  }

  build() {
    Column({ space: 20 }) {
      // 状态弹窗:全局组件,不影响布局
      CollaborationServiceStateDialog({
        onState: (stateCode: number, bufferType: string, buffer: ArrayBuffer): void =>
          this.doInsertPicture(stateCode, bufferType, buffer)
      })

      // 按钮:点击后弹出设备选择菜单
      Button('使用远端设备进行拍照')
        .type(ButtonType.Normal)
        .borderRadius(10)
        .bindMenu(this.MyTestMenu)

      // 图片展示区:拍照完成后显示回传的照片
      if (this.picture) {
        Image(this.picture)
          .borderStyle(BorderStyle.Dotted)
          .borderWidth(1)
          .objectFit(ImageFit.Contain)
          .height('80%')
          .onComplete((event) => {
            if (event != undefined) {
              hilog.info(0, "MEMOMOCK", "onComplete " + event.loadingStatus)
            }
          })
      }
    }
    .padding(20)
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }
}

整个页面结构很清晰:顶部是全局的状态弹窗(用户感知不到它的存在,除非有状态需要提示),中间是触发操作的按钮,底部是照片展示区域。

按钮通过 .bindMenu(this.MyTestMenu) 绑定了设备列表菜单。用户点击按钮后,会看到周围可用的设备列表,选中某台设备就会唤起该设备的相机。

3.3 处理回传的照片数据

doInsertPicture(stateCode: number, bufferType: string, buffer: ArrayBuffer): void {
  if (stateCode != 0) {
    return
  }
  if (bufferType == "general.image") {
    let imageSource = image.createImageSource(buffer)
    imageSource.createPixelMap().then((pixelMap) => {
      this.picture = pixelMap;
    })
  }
}

这段逻辑是整个流程的"收尾"环节:

  1. 先检查 stateCode 是否为 0(即操作成功),不成功则直接返回。
  2. 确认回传数据类型是图片("general.image")。
  3. ImageKitcreateImageSourceArrayBuffer 创建图片源,再转成 PixelMap
  4. 赋值给状态变量 picture,触发页面刷新,照片就显示出来了。

整个过程中,开发者不需要关心设备发现、连接建立、数据传输这些底层细节,框架已经把这些封装好了。你只需要关注"要什么能力"和"拿到数据后怎么用"。


四、总结与实践建议

跨设备互通的核心价值在于打破了单台设备的能力边界。对于平板、2in1 这类大屏设备来说,它补齐了拍照和扫描的短板;对于多设备协作的场景,它省去了拍照后手动传文件的繁琐操作。

从开发角度看,这项能力的接入成本并不高。核心就是两个组件的配合使用:createCollaborationServiceMenuItems 负责设备发现和选择,CollaborationServiceStateDialog 负责状态提示和数据回传。开发者的主要工作集中在 onState 回调中——根据回传的数据类型,做好业务层面的处理即可。

在实际接入时,有几点值得注意:

  • 能力过滤要精准:如果你的场景只需要拍照,传 TAKE_PHOTO 而不是 ALL,可以让设备列表更准确,避免用户看到不相关的选项。
  • 同一局域网能提速:虽然 WLAN + 蓝牙是最低要求,但接入同一局域网对唤醒速度有明显帮助,在产品文案或引导中可以提示用户。
  • 关注 API 版本差异:6.1.0(23) 前后的调用方向有变化。如果你的应用需要兼容较早版本,在设备能力判断上要做好区分。
  • 回传数据类型判断onState 回调中的 bufferType 会区分图片和视频等不同类型,做好类型判断再处理,避免数据解析出错。

对于已经在做多设备协同场景的应用来说,跨设备互通是一个值得优先接入的能力——它的使用门槛低,但带来的体验提升是用户能直接感知到的。

鸿蒙网络调试利器:用网络领航员模拟真实网络场景

引言

移动应用的用户体验,在很大程度上取决于它在各种网络环境下的表现。地铁穿行时刷短视频会不会卡顿?进出电梯的瞬间请求会不会断掉?地下车库里能不能正常加载内容?这些问题往往需要开发者跑到真实场景中才能验证,测试成本极高。

鸿蒙系统从 API version 20 开始,为手机和平板设备提供了网络领航员功能。它的核心价值很直接——让开发者坐在工位上,就能模拟各种典型的网络场景,快速验证应用体验,并获得针对性的网络优化建议。

本文面向有一定鸿蒙开发基础的开发者,系统梳理网络领航员的功能定位、使用方法、场景设计思路,以及配套的代码优化实践。


一、网络领航员解决什么问题

传统的网络体验测试面临两个痛点:一是场景复现成本高,你不可能为了测一个高铁场景就真的去坐高铁;二是网络状态不可控,即便到了目标场景,网络条件也在不断变化,难以做对比测试。

网络领航员提供的就是一种可控、可重复的网络模拟能力。它预置了 8 种典型网络场景,涵盖了日常出行中最常遇到的网络波动情形。开发者启用某个场景后,设备的网络行为会按照预设的参数运行——包括带宽、时延、丢包率的变化,甚至网络接口(WLAN 与蜂窝)的切换——从而在可控条件下观察应用的表现。

同时,网络领航员还与 Network Boost Kit 形成联动。开发者不仅能发现问题,还能获得该场景下的数据传输优化建议,通过接入 Network Boost Kit 实现应用与系统的跨层协同,提升上网流畅度。


二、如何使用网络领航员

2.1 开启入口

网络领航员有两种入口:设置界面HDC 命令行(即 netcopilot 工具)。

通过设置界面操作的路径为:

设置 → 系统 → 开发者选项 → 网络领航员

首次使用前需要先开启设备的开发者模式。

2.2 启用预置场景

进入网络领航员页面后,可以看到所有预置的网络模拟场景。点击任意一个场景即可启用,启用后可以在页面上查看当前生效的模拟场景,同时设备的实况窗会实时展示当前的网络模拟内容。

通过实况窗,开发者不仅能看到网络状态的变化,还可以点击进入查看该场景的详细介绍、网络参数,以及对应的代码开发最佳实践。

2.3 停止场景

需要停止模拟时,点击场景右侧的操作按钮,选择退出即可。

2.4 自定义网络场景

当预置场景无法满足需求时,可以通过页面底部的**"添加自定义网络场景"**来配置专属的网络参数。可配置的参数及其取值范围如下:

参数 取值范围 单位
上行带宽 100 - 500,000 Kbps
下行带宽 100 - 2,000,000 Kbps
上行丢包率 0.0 - 20.0 %
下行丢包率 0.0 - 20.0 %
上行延迟 1 - 1,000 ms
下行延迟 1 - 1,000 ms

需要注意的是,自定义场景的参数配置会应用于启动时的默认网络,建议在网络环境稳定时使用。此外,自定义场景暂未接入 Network Boost Kit,如果需要模拟网络场景的注册回调能力,应使用预置场景。


三、8 大预置场景解析

网络领航员预置的 8 种场景,覆盖了移动用户日常中最典型的网络波动情形。理解每个场景的设计思路,有助于开发者更有针对性地进行测试。

3.1 涉及网络切换的场景

以下三个场景的核心特征是 WLAN 与蜂窝网络之间的切换,使用前需要同时连接 WLAN 并开启移动网络。

进出电梯(总时长 5 分钟,循环执行):模拟了一个完整的"等电梯 → 进入电梯 → WLAN 信号消失 → 切换到蜂窝 → 出电梯 → 恢复 WLAN"的全过程。其中最关键的阶段是电梯关门后的 30 秒——WLAN 带宽从 1Mbps 线性下降至 0,时延飙升到 1000ms,丢包率升至 8%,随后切换到蜂窝网络逐步恢复。这个过程是考验应用网络切换处理能力的典型场景。

离家断开 WLAN(总时长 3 分钟,循环执行):模拟用户从家中走向户外的过程。WLAN 信号逐渐减弱直到不可用,然后切换到蜂窝网络。最终蜂窝网络稳定后,下行带宽可达 300-500Mbps。

到家连接 WLAN(总时长 3 分钟,循环执行):与上一个场景相反,模拟从户外回到室内、蜂窝网络切换到 WLAN 的过程。

3.2 涉及网络波动的场景

这类场景的特征是网络信号在强弱之间快速变化,使用前连接 WLAN 或移动网络即可(乘坐高铁场景需插入双 SIM 卡)。

乘坐地铁(总时长 5 分钟,循环执行):模拟地铁在站台与行驶区间交替的网络表现。站台上网络良好(下行 300-500Mbps),列车行驶时降至 0-10Mbps 并伴随丢包,到站后恢复——如此往复。

乘坐高铁(总时长 10 分钟,循环执行):这是时长最长的场景,模拟了高铁从站台出发、经过市郊、进入山区、在山区发生 SIM 卡切换(从卡 1 切到卡 2)、再回到市郊到站的完整旅程。山区阶段的时延高达 300-2000ms,丢包率最高 5%,是对应用极端网络容忍度的严苛测试。

高速公路自驾(总时长 5 分钟,循环执行):模拟城区行驶与穿越隧道交替出现的网络变化。隧道中下行带宽骤降至 0-2Mbps,时延升至 300-2000ms,丢包率达 5%。

3.3 网络持续不佳的场景

拥挤的食堂(总时长 5 分钟,循环执行):模拟网络接入设备众多、空口竞争激烈的环境。排队取餐阶段带宽仅 0-5Mbps,时延 10-100ms,丢包率高达 2.4%。就坐后网络有所恢复但仍不理想。

信号弱的地库(总时长 5 分钟,循环执行):模拟地下停车场信号覆盖差的情况。在停车场内的 3 分钟里,蜂窝网络带宽仅 0-10Mbps,时延 30-100ms。


四、面向三类网络特征的代码优化实践

8 种预置场景的网络特征,可以归纳为三类:网络切换网络波动网络质量差。针对每类特征,网络领航员配套提供了相应的优化建议。

4.1 网络切换:及时感知,快速重建连接

当设备在 WLAN 与蜂窝网络之间切换,或在多 SIM 卡之间切换时,会带来 IP 地址变更、TCP 连接被强制中断(触发 RST 复位),以及 DNS 重解析和新连接握手的延迟。

应对策略的核心是:及时识别网络切换,重建连接,重新发起未完成的请求。

一个直接的做法是使用 Network Kit 的 HTTP 数据请求模块发起网络请求。Network Kit 集成了通途协议栈的智能多网切换功能,能够在网络切换时自动将待发和进行中的请求切换到已激活的网络,避免请求失败或响应延迟。示例写法如下:

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

let httpRequest = http.createHttp();
httpRequest.request(
  "EXAMPLE_URL", (err: BusinessError, data: http.HttpResponse) => {
    if (!err) {
      console.info('Result:' + JSON.stringify(data.result));
      httpRequest.destroy();
    } else {
      console.error('error:' + JSON.stringify(err));
      httpRequest.destroy();
    }
  }
);

需要注意的是,每一个 httpRequest 对应一个独立的 HTTP 请求任务,不可复用。请求完成后应调用 destroy() 方法主动销毁。

4.2 网络波动:实时感知质量,动态调整策略

地铁穿行、高速公路驾驶等场景中,信号强度快速变化,带宽可能从 50Mbps 骤降到 1Mbps,RTT 波动超过 300%,并伴随短时丢包。

应对这类场景的关键在于实时感知网络质量的变化,动态调整请求行为,具体有两个方向:

第一,订阅网络质量变化事件。 通过 Network Boost Kit 的 netQosChange 事件,可以实时获取当前数据链路的上下行带宽等信息,据此判断网络质量并调整请求策略:

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

try {
  netQuality.on('netQosChange', (list: Array<netQuality.NetworkQos>) => {
    if (list.length > 0) {
      list.forEach((qos) => {
        console.info(`上行带宽: ${JSON.stringify(qos.linkUpBandwidth)}`);
        console.info(`下行带宽: ${JSON.stringify(qos.linkDownBandwidth)}`);
        // 根据带宽信息调整请求策略
      });
    }
  });
} catch (err) {
  console.error('errCode: ' + (err as BusinessError).code +
    ', errMessage: ' + (err as BusinessError).message);
}

第二,在网络质量好时进行预取。 趁网络状态良好,提前发送后续可能产生的请求,缓存获取的资源。等真正需要这些资源时直接加载缓存,从而规避网络恶化带来的卡顿。可以借助 Remote Communication Kit 实现预取逻辑:

import { rcp } from '@kit.RemoteCommunicationKit';
import { HashMap } from "@kit.ArkTS";

export class PrefetchingRcp {
  private session = rcp.createSession();
  private responsePrefetched: HashMap<string, rcp.Response> = new HashMap();

  public async prefetch(url: string) {
    const request = new rcp.Request(url);
    try {
      let response: rcp.Response = await this.session.fetch(request);
      this.responsePrefetched.set(url, response);
      return Promise.resolve();
    } catch (reason) {
      console.error(`Rcp prefetch failed: ${reason.code}`);
      return Promise.reject();
    }
  }
}

在网络质量差时,则应反过来降低请求频率和请求数据量,避免加剧网络拥塞。

4.3 网络质量差:识别拥塞场景,主动降级

在地下停车场、拥挤的食堂这类场景中,网络呈现持续低带宽(<1Mbps)、高延迟(>100ms)和高丢包率的特征。

除了通过上述 netQosChange 判断网络质量外,Network Boost Kit 还提供了网络场景识别事件 netSceneChange,可以直接检测网络拥塞或信号差的状态。这让应用能够更精准地判断当前处于何种网络环境,并做出相应处理——检测到拥塞时减少请求,恢复正常后恢复请求频率:

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

try {
  netQuality.on('netSceneChange', (list: Array<netQuality.NetworkScene>) => {
    if (list.length > 0) {
      list.forEach((sceneInfo) => {
        if (sceneInfo.scene == 'congestion') {
          // 网络拥塞,减少请求频率
        }
        if (sceneInfo.scene == 'normal') {
          // 拥塞解除,恢复正常请求
        }
        if (sceneInfo.weakSignalPrediction) {
          // 弱信号预测:网络质量即将变差,提前应对
        }
      });
    }
  });
} catch (err) {
  console.error('errCode: ' + (err as BusinessError).code +
    ', errMessage: ' + (err as BusinessError).message);
}

值得关注的是 weakSignalPrediction 这个字段——它提供的是弱信号预测能力,让应用能够在网络质量实际变差之前就提前做好准备,这对用户体验的提升是非常有价值的。


五、关于数据收集

使用网络领航员时,需要注意以下数据收集事项:

  • 使用前需要关联华为账号,选择打开"关联体验改进计划"开关即表示同意上传使用日志;
  • 会收集应用的 bundle name 及版本号;
  • 会收集使用的网络模拟场景及对应时间;
  • 华为可能通过账号对应的联系方式进行使用回访。

六、总结

网络领航员为鸿蒙开发者提供了一种低成本、可控、可重复的网络场景测试手段。它的价值不仅在于模拟本身,更在于围绕模拟场景形成了"发现问题 → 理解问题 → 解决问题"的完整闭环:

  1. 发现问题:通过 8 种预置场景和自定义场景,覆盖网络切换、网络波动、网络质量差三类典型网络特征,在可控条件下暴露应用的网络兼容性问题。
  2. 理解问题:通过实况窗实时观察网络状态变化,结合每个场景细致的阶段划分和参数设计,准确定位问题出现的时机和条件。
  3. 解决问题:结合 Network Kit 的智能多网切换、Network Boost Kit 的网络质量感知和场景识别能力,以及预取缓存等策略,针对性地优化应用的网络处理逻辑。

对于希望进一步提升应用网络体验的开发者,建议从与自身业务最相关的场景入手——如果你的应用主要在通勤场景中使用,重点关注地铁和电梯场景;如果是室内场景居多,则侧重食堂和停车场场景的测试。在验证基本表现后,逐步接入 Network Boost Kit 的事件订阅机制,实现更精细的网络自适应策略。

❌