HarmonyOS 精准分享目标校验与投递状态机(连载01)【共创稿事节】

在会议室里整理建筑巡检照片时,手机上选中了数张现场图片,平板端同时打开图纸、会议纪要和工程看板。如果分享动作只携带一组文件 URI,接收应用仍无法回答一个关键问题:素材到底要插入哪一个窗口、哪一块画布,以及此时这个位置是否仍然有效。HarmonyOS 7 的“碰一碰·精准分享”将目标窗口和触碰坐标纳入交互场景,应用侧则应把目标身份、会话有效期和用户确认做成可检查的业务流程。

跨设备目标窗口校验与素材投递流程

一、将系统触点与业务插入动作分开

这里采用“现场素材协同”应用作为工程场景。发送端管理照片、图纸以及备注卡片;接收端的编辑器包含图纸画布、评论区与附件栏。前者保存选中素材,后者决定合法的落点。目标窗口可能切换、关闭或从前台退到后台,而触碰发生的瞬间和素材真正写入的时间之间存在延迟。

应将业务链路拆成五个节点:发送端确定素材集合;接收端获得当前目标窗口与落点;双方关联一次投递会话;接收端再次核对目标;提交插入并产生结果回执。前两个节点不应直接触发不可逆的写入操作。尤其是窗口刚关闭、编辑器被切换到另一份图纸、连续触发两次触点等情况,需要在业务层拒绝旧事件。

官方全场景能力页面介绍了精准分享识别目标窗口与触碰坐标的场景。实际接入时,将已取得的目标数据交给本文的 TargetEnvelope 即可,底层接入适配与应用业务校验相互隔离。本文使用的 TargetEnvelope、DeliveryGuard 是应用内部类型,不属于 HarmonyOS 系统接口。

二、确定目标对象与会话结构

目标身份不能只有一个窗口标题。“工程总览”可能在同一台平板上打开多份,而标题也可能在编辑过程中变化。业务对象需要包含窗口标识、文档标识、视图修订号、可接收区域和触点时间。触点坐标统一以当前接收视图的局部坐标表示;跨窗口坐标转换将在后续篇章处理。

// entry/src/main/ets/common/DeliveryModel.ets
export type DeliveryPhase = 'idle' | 'checking' | 'ready' |
  'sending' | 'completed' | 'failed';

export interface TouchPoint {
  x: number;
  y: number;
  at: number;
}

export interface TargetEnvelope {
  windowId: string;
  documentId: string;
  viewRevision: number;
  width: number;
  height: number;
  point: TouchPoint;
  visible: boolean;
}

export interface DeliveryRequest {
  requestId: string;
  assetIds: string[];
  target: TargetEnvelope;
  createdAt: number;
}

export interface GuardResult {
  ok: boolean;
  reason: string;
}

requestId 用于识别一次操作,assetIds 保存素材业务主键,不把相册 URI 充当跨设备永久主键。documentId 决定目标文档,viewRevision 表示接收视图内容版次。窗口身份相同而版次不同,同样需要重新核对落点。界面在展示“准备投递”时仍属于待确认状态。

DevEco Studio 中的目标验证状态逻辑与界面预览

三、通过状态机防止旧目标被写入

一个简短的时间窗口能减少目标过期,但单靠超时仍不够。如果用户在两秒内切换了文档,时间未过期的触点同样无效。因此同时约束请求标识、窗口、文档、修订号、触点范围及时间。下面的类只管理业务校验,不涉及跨设备通信方式。

// entry/src/main/ets/common/DeliveryGuard.ets
import {
  DeliveryPhase, DeliveryRequest, GuardResult, TargetEnvelope
} from './DeliveryModel';

export class DeliveryGuard {
  private phase: DeliveryPhase = 'idle';
  private current: DeliveryRequest | null = null;
  private readonly ttlMs: number = 5000;
  private completedIds: Set<string> = new Set<string>();

  begin(request: DeliveryRequest): GuardResult {
    if (this.phase === 'sending') {
      return { ok: false, reason: '已有投递正在处理' };
    }
    if (request.assetIds.length === 0 || request.assetIds.length > 12) {
      return { ok: false, reason: '请选择1至12项素材' };
    }
    if (this.completedIds.has(request.requestId)) {
      return { ok: false, reason: '该投递已结束' };
    }
    this.current = request;
    this.phase = 'checking';
    return { ok: true, reason: '正在核对目标窗口' };
  }

  verify(live: TargetEnvelope, now: number): GuardResult {
    const req: DeliveryRequest | null = this.current;
    if (req === null || this.phase !== 'checking') {
      return { ok: false, reason: '当前没有待校验的投递' };
    }
    if (now - req.createdAt > this.ttlMs || now < req.createdAt) {
      return this.reject('目标会话已过期');
    }
    const saved: TargetEnvelope = req.target;
    if (!live.visible || saved.windowId !== live.windowId ||
        saved.documentId !== live.documentId ||
        saved.viewRevision !== live.viewRevision) {
      return this.reject('目标窗口或文档已变化');
    }
    const p = saved.point;
    if (!Number.isFinite(p.x) || !Number.isFinite(p.y) ||
        p.x < 0 || p.y < 0 || p.x >= live.width || p.y >= live.height) {
      return this.reject('触点超出可投递区域');
    }
    this.phase = 'ready';
    return { ok: true, reason: '目标已核对,等待用户确认' };
  }

  commit(requestId: string): GuardResult {
    if (this.current === null || this.phase !== 'ready' ||
        this.current.requestId !== requestId) {
      return { ok: false, reason: '投递会话已改变' };
    }
    this.phase = 'sending';
    return { ok: true, reason: '可提交素材写入' };
  }

  finish(requestId: string, success: boolean): void {
    if (this.current === null || this.current.requestId !== requestId) return;
    this.phase = success ? 'completed' : 'failed';
    if (success) this.completedIds.add(requestId);
  }

  private reject(reason: string): GuardResult {
    this.phase = 'failed';
    return { ok: false, reason: reason };
  }

  getPhase(): DeliveryPhase { return this.phase; }
}

这里的 commit() 只是放行提交,真正的素材写入由接收端业务服务执行。接收服务收到请求后还须用当时的实时窗口信息核验一次,避免“校验完成后、写入开始前”出现窗口切换。一次成功回执才将请求加入已处理集合。多进程或跨设备部署时,该集合应下沉到能够持久化并按请求 ID 原子去重的服务中。

四、把用户确认放到正确的位置

完成目标校验后,手机端可展示接收应用、目标文档、素材数和落点摘要。确认按钮使用真实的业务状态,而不是点击后直接显示成功。接收端窗口失焦时,界面显示“重新选择目标”;会话超时则取消当前确认并保留用户已选素材。

// entry/src/main/ets/pages/Index.ets
import { DeliveryGuard } from '../common/DeliveryGuard';
import { DeliveryRequest, TargetEnvelope } from '../common/DeliveryModel';

@Entry
@Component
struct Index {
  @State phaseText: string = '请选择素材和接收目标';
  @State canCommit: boolean = false;
  private guard: DeliveryGuard = new DeliveryGuard();
  private requestId: string = '';

  private prepare(target: TargetEnvelope): void {
    this.requestId = `delivery-${Date.now()}`;
    const request: DeliveryRequest = {
      requestId: this.requestId,
      assetIds: ['site-photo-01'],
      target: target,
      createdAt: Date.now()
    };
    const started = this.guard.begin(request);
    const checked = started.ok ? this.guard.verify(target, Date.now()) : started;
    this.canCommit = checked.ok;
    this.phaseText = checked.reason;
  }

  build() {
    Column({ space: 16 }) {
      Text('现场素材投递').fontSize(25).fontWeight(FontWeight.Bold)
      Text(this.phaseText).fontSize(15).fontColor('#64748B')
      Text('当前素材:现场照片 · 1项').fontSize(16)
      Button('确认投递')
        .enabled(this.canCommit)
        .onClick(() => {
          const result = this.guard.commit(this.requestId);
          this.phaseText = result.reason;
          this.canCommit = false;
          // 由接收端服务消费请求并回传写入回执
        })
      Button('重新选择目标').buttonStyle(ButtonStyleMode.TEXTUAL)
        .onClick(() => {
          this.phaseText = '等待新目标窗口';
          this.canCommit = false;
        })
    }
    .padding(24).width('100%')
  }
}

以上页面仅表达业务按钮与状态反馈。正式接入时,prepare() 的参数应从适配层传入,而不应由界面自行猜测目标坐标。生命周期中还应撤销与当前页面绑定的订阅,防止页面退出后收到延迟回执。图中的不同状态对应等待选择、校验通过以及重选目标的处理节点。

手机端目标核验与确认投递界面

五、保留可复盘的失败原因

调试时,最有价值的并非“投递失败”四个字,而是失败发生在哪个阶段。可按 requestId 记录从目标接收、身份核对、用户确认、提交到回执的状态变化;日志记录窗口标识的摘要、文档标识及错误类别,避免写入完整个人照片路径与不必要的内容数据。

现象应检查的数据处理方式
点击确认后目标换成另一张图纸documentId、viewRevision终止本次写入并请求重新确认
触点落在窗外x、y、窗口实际宽高拒绝目标并要求重新选择
同一触碰导致两次请求requestId、接收端去重记录第二次返回已处理状态
接收端窗口已关闭visible、窗口标识保留选中素材,清除目标会话
回执延迟到达请求 ID、当前页面会话只更新匹配的会话状态

在大屏编辑器里,目标窗口与文档修订号经常是两个变化节奏不同的量。窗口保持不变时,用户可能连续切换两份设计图;文档保持不变时,用户可能缩放、移动画布,使相同屏幕坐标对应完全不同的业务位置。为了控制这些变化,窗口和文档身份由目标验证器负责,画布变换则由下一阶段的坐标映射器负责。验证顺序也有讲究:先验证请求是否过期,再验证身份,随后验证点是否仍处于接收区,最后才允许用户确认。这样可以避免不合法目标继续触发大尺寸图片读取,减少无效的资源消耗。

手工检查可以围绕四组状态变化展开:同一目标连续点击、在确认页切换文档、窗口尺寸发生改变以及返回上一页再进入。每组都要关注“旧触点是否仍可被提交”和“错误回执是否错误覆盖新会话”。这比只检查最终提示语更能发现时序问题。

六、与系统分享的协作位置

对于只需要正常跨应用发送文本的入口,Share Kit 提供 SharedData 与 ShareController。其调用方式与精准落点业务不同:打开分享面板可以作为普通分享路径,但面板显示完成不等于接收端完成了目标文档写入。

import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

// 放在 @Component 内的方法
private async shareText(): Promise<void> {
  const data = new systemShare.SharedData({
    utd: utd.UniformDataType.TEXT,
    content: 'A区外立面巡检记录',
    title: '现场备注'
  });
  const controller = new systemShare.ShareController(data);
  const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
  await controller.show(context, {
    selectionMode: systemShare.SelectionMode.SINGLE,
    previewMode: systemShare.SharePreviewMode.DETAIL
  });
}

这条普通分享路径使用公开文档中的 Share Kit 接口;目标窗口与触点数据仍由对应的精准分享接入链路提供。两种路径复用素材选择与业务校验,但不混淆完成回执含义。下一篇将继续实现照片选择、URI 暂存和素材封装,使一次投递从“目标可信”推进到“内容可用”。

参考文档:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐