[鸿蒙从零到一] HarmonyOS 分布式能力与设备协同实战:从发现设备到任务闭环

[鸿蒙从零到一] HarmonyOS 分布式能力与设备协同实战:从发现设备到任务闭环

HarmonyOS 的分布式能力,核心不是把同一个页面简单复制到另一台设备,而是让应用根据设备能力和用户场景,把合适的任务交给合适的设备完成。手机负责选择内容,平板负责展示,智慧屏负责播放,手表负责提醒,这些协作都需要明确的设备发现、能力协商、任务分发和结果回传。

如果直接在页面里调用设备连接 API,功能很快就能跑起来,但后续会遇到设备上下线、权限变化、网络抖动、任务重复执行和状态不同步等问题。更稳妥的做法是把分布式能力封装成独立服务,让页面只关心“用户要做什么”,而不是“当前有哪些设备以及连接细节”。

本文以“手机把正在阅读的文章交给平板继续阅读”为例,从概念和架构开始,逐步实现一条可恢复的设备协同链路。示例 API 需要结合当前 DevEco Studio 和 SDK 版本的类型定义调整,重点在于工程结构和状态处理方式。

一、先区分设备协同的几类问题

设备协同通常包含四个不同层次,不能混在一个函数里处理:

  • 设备发现:找到当前可见、可连接且满足场景要求的设备。
  • 能力协商:确认目标设备支持哪些能力、屏幕尺寸和数据格式。
  • 任务分发:把一个明确的业务任务发送给目标设备,并生成任务标识。
  • 状态同步:让源设备和目标设备对任务进度、结果和失败原因保持一致。

例如,手机发现了一台平板,并不代表平板一定能打开当前文章。平板可能没有对应应用、没有登录同一账号,或者只支持纯文本而不支持富媒体。发现结果只是候选集合,真正分发前还要做能力过滤和业务校验。

二、把协同场景建模成任务

设备之间传递的最好不是一堆散落字段,而是一个有版本的任务对象。任务对象应包含来源、目标、动作、参数和幂等标识,方便日志追踪和重试。

interface ContinueReadingTask {
  schemaVersion: 1
  taskId: string
  sourceDeviceId: string
  targetDeviceId: string
  action: 'openArticle'
  articleId: string
  position: number
  requestedAt: number
}

interface TaskResult {
  taskId: string
  status: 'accepted' | 'running' | 'succeeded' | 'failed'
  errorCode?: string
  message?: string
}

taskId 不能只依赖当前时间。它需要在一次协同流程中保持唯一,并且在重试时继续使用同一个值。目标设备收到相同 taskId 时,应识别为重复投递,返回已有结果,而不是再次打开页面或创建重复任务。

任务数据也要控制大小。文章正文、图片和视频不适合直接塞进跨设备消息,消息里可以传递内容标识和必要的进度,目标设备再根据授权从服务端或共享数据源获取正文。

三、设备发现只负责提供候选项

设备发现层应该输出统一的候选设备模型,隐藏底层设备发现方式的差异。应用层不要直接依赖设备 ID 字符串或原始能力列表。

interface DeviceCapability {
  deviceId: string
  deviceName: string
  deviceType: 'phone' | 'tablet' | 'tv' | 'watch' | 'unknown'
  capabilities: string[]
  isOnline: boolean
  isTrusted: boolean
}

interface DeviceDiscovery {
  async listCandidates(required: string[]): Promise<DeviceCapability[]> {
    const devices = await this.queryNearbyDevices()
    return devices.filter((device) => {
      return device.isOnline && device.isTrusted &&
        required.every((item) => device.capabilities.includes(item))
    })
  }

  private async queryNearbyDevices(): Promise<DeviceCapability[]> {
    // 在这里适配 HarmonyOS 的设备发现和能力查询 API。
    return []
  }
}

真实项目中,设备发现可能受蓝牙、局域网、同账号关系、用户授权和系统策略影响。isTrusted 不应该由网页或远端参数决定,而应由系统连接状态和应用自己的信任策略共同确认。

发现结果还会变化。UI 展示候选设备时,应该订阅设备上下线事件,并在用户点击前再次确认目标仍然在线。不能把页面打开时的列表当成永久有效的连接信息。

四、能力协商决定能不能交付

能力协商的目标不是比较设备型号,而是确认目标是否能完成这次具体任务。可以把能力定义成业务能力,例如 article.readimage.decodevideo.play,而不是把设备名称写死在业务判断中。

interface ArticleRequirements {
  required: string[]
  preferred: string[]
  maxPosition: number
}

function canContinueReading(
  device: DeviceCapability,
  requirement: ArticleRequirements
): boolean {
  return requirement.required.every((item) =>
    device.capabilities.includes(item))
}

必要能力缺失时,应在源设备上给出可理解的失败原因,例如“目标设备不支持当前内容格式”。偏好能力缺失则可以降级,例如目标设备没有高清屏,就发送低分辨率封面;目标设备不支持互动内容,就只发送文章标题和阅读位置。

协商结果可以缓存一小段时间,但不能无限缓存。设备系统升级、应用更新和用户权限变化都可能改变能力集合。对于支付、文件访问和账号相关能力,每次任务都应在目标设备侧再次校验。

五、用分层服务隔离平台 API

推荐把设备协同拆成四层:

  • DeviceDiscovery:负责发现设备、监听上下线和查询能力。
  • CapabilityMatcher:负责按任务要求筛选目标设备。
  • DistributedTaskTransport:负责建立连接、发送任务和接收回执。
  • TaskCoordinator:负责状态机、超时、重试、幂等和业务回调。

页面只调用协调器:

class ReadingCoordinator {
  constructor(
    private readonly discovery: DeviceDiscovery,
    private readonly transport: DistributedTaskTransport
  ) {}

  async continueOnTablet(articleId: string, position: number): Promise<void> {
    const candidates = await this.discovery.listCandidates(['article.read'])
    const target = candidates.find((item) => item.deviceType === 'tablet')
    if (!target) {
      throw new Error('NO_COMPATIBLE_DEVICE')
    }

    const task: ContinueReadingTask = {
      schemaVersion: 1,
      taskId: createTaskId(),
      sourceDeviceId: await this.currentDeviceId(),
      targetDeviceId: target.deviceId,
      action: 'openArticle',
      articleId,
      position,
      requestedAt: Date.now()
    }
    await this.transport.submit(task)
  }

  private async currentDeviceId(): Promise<string> {
    return 'current-device'
  }
}

上面的协调器没有直接依赖具体连接 API,因此可以用假的发现服务和传输服务做单元测试。后续把传输方式从局域网切换到系统提供的分布式接口时,页面和业务模型不需要一起改动。

六、任务分发要有回执和状态机

发送成功只说明源设备把消息交给了传输层,不代表目标设备已经完成任务。至少要区分以下状态:

  • created:任务已生成,尚未发送。
  • submitted:传输层已接受发送请求。
  • accepted:目标设备确认理解任务并准备处理。
  • running:目标设备正在执行。
  • succeeded:目标设备完成并返回结果。
  • failed:任务无法完成,包含稳定错误码。
  • expired:超过有效期,后续回执不再改变业务结果。

状态只能沿允许的方向流转,并且所有变化都带上 taskId。源设备收到旧回执时要丢弃,收到重复的成功回执时也不能重复触发页面跳转。

const transitions: Record<string, string[]> = {
  created: ['submitted', 'failed'],
  submitted: ['accepted', 'failed', 'expired'],
  accepted: ['running', 'failed', 'expired'],
  running: ['succeeded', 'failed', 'expired'],
  succeeded: [],
  failed: [],
  expired: []
}

function moveTask(current: string, next: string): string {
  if (!transitions[current]?.includes(next)) {
    throw new Error(`INVALID_TASK_TRANSITION: ${current} -> ${next}`)
  }
  return next
}

目标设备应先持久化任务接收记录,再返回 accepted。这样即使进程在返回回执后立即退出,恢复时也能根据任务记录继续处理,而不是让源设备误以为任务从未到达。

七、断线、超时和重试怎么处理

设备协同天然会遇到网络变化和设备离开范围。重试策略要和任务动作的幂等性一起设计:

  • 查询类动作可以快速重试,但要设置总超时时间。
  • 打开页面、播放媒体等动作应使用同一个 taskId 重试,避免重复创建任务。
  • 涉及支付、删除和提交订单的动作不能由通用重试器自动重放。
  • 目标离线时保留任务摘要,待设备重新上线后由用户确认是否继续。
async function submitWithRetry(
  transport: DistributedTaskTransport,
  task: ContinueReadingTask,
  maxAttempts: number
): Promise<void> {
  let lastError: unknown
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    try {
      await transport.submit(task)
      return
    } catch (error) {
      lastError = error
      await backoff(attempt)
    }
  }
  throw lastError ?? new Error('TASK_SUBMIT_FAILED')
}

这里的 backoff 不能无限等待,应用退到后台时还要配合任务调度能力决定是否继续。对用户可见的操作,应展示“正在连接”“目标设备不可用”或“已在目标设备打开”等明确状态,而不是一直停留在加载动画。

八、数据安全和权限边界

设备协同不等于设备之间可以互相读取全部应用数据。发送前要做最小化处理:

  • 只传递目标设备完成任务所必需的字段。
  • 使用短期任务令牌或服务端授权,不把长期账号密钥放进消息。
  • 在源设备和目标设备两侧都校验用户身份、应用身份和任务权限。
  • 对文章、图片和文件使用内容标识,避免在消息中传递不必要的原始数据。
  • 日志只记录任务 ID、动作、耗时和错误码,避免记录令牌、正文和个人信息。

目标设备不能仅凭“来自可信设备”就执行敏感动作。可信关系解决的是通信来源问题,业务授权解决的是“这个用户能否执行这项操作”。两者必须分别验证。

如果任务包含本地文件,不能直接发送应用沙箱路径。应由源设备提供受控的数据读取接口,目标设备通过一次性授权获取内容,完成后及时释放临时文件和权限。跨设备传输失败时,也要清理已经创建的临时资源。

九、让用户始终知道任务在哪里

协同功能的体验重点是状态透明。源设备可以保留一条简短的任务记录,显示目标设备名称、当前状态、最近更新时间和失败原因。目标设备打开任务后,也要能回传“已接收”和“已完成”状态。

不要把设备协同设计成完全隐式的自动跳转。第一次使用或涉及敏感内容时,应让用户确认目标设备;设备名称相近时,显示设备类型和可识别信息,避免误投。

对于“继续阅读”这类场景,目标设备可以回传实际打开的位置。源设备收到结果后更新本地阅读进度,但要处理时间戳冲突:较旧设备上产生的进度不能覆盖较新的进度。可以使用任务时间、内容版本和来源设备信息做判断。

十、测试清单和常见误区

分布式功能至少要覆盖以下场景:

  • 没有候选设备、候选设备能力不足和目标设备中途离线。
  • 任务重复发送、回执乱序、回执重复和源设备重启。
  • 目标设备应用未安装、版本不兼容、用户未登录或权限被撤销。
  • 传输成功但目标业务执行失败,以及目标执行成功但回执丢失。
  • 横竖屏切换、应用进入后台、设备网络从局域网切换到移动网络。
  • 任务包含超长文本、特殊字符、过期时间和非法参数。

常见误区有三个。第一,把设备型号当成能力判断依据,导致新设备或不同地区设备无法适配。第二,只判断发送 API 是否返回成功,忽略目标业务状态。第三,用全局变量保存连接和任务,页面销毁后仍然接收旧回调,造成内存泄漏和状态错乱。

总结

HarmonyOS 分布式能力的工程重点,是把“跨设备调用”变成一条有边界、可追踪、可恢复的业务任务链路。设备发现提供候选项,能力协商确认可行性,任务协调器负责状态和重试,传输适配层负责对接具体平台 API。

当任务具备版本、唯一标识、回执、超时和幂等处理后,设备协同就不再依赖理想网络环境。无论是继续阅读、跨屏播放还是设备间表单接续,都可以在相同的架构上逐步扩展,同时保持页面代码简洁,安全边界清晰。

posted @ 2026-08-03 11:05  天总会晴的  阅读(1)  评论(0)    收藏  举报