两个人一起整理旅行照片,一台手机里有街角建筑,另一台设备上准备继续挑图、写说明。选出照片只是开始,接下来还要找到对方、确认发送、接收,再把素材放进正在做的事情里。

传文件通常被看成一段独立流程,写作和整理则在传完之后继续。如果这些步骤之间需要反复寻找入口,数据虽然已经到了,任务却还没有真正接上。

HarmonyOS 7 的碰一碰分享,把设备接触变成一个直观的发起动作;精准分享进一步把目标窗口和触碰位置带进内容交接。理解它们的价值,需要同时看“传给谁”以及“到了以后做什么”。

一、物理动作表达意图,系统与应用接住后续过程

在官网介绍中,碰一碰分享面向近场设备间的数据传输;碰一碰·精准分享则让手机轻触支持的电脑或平板屏幕,识别目标窗口与触碰坐标,将图片等素材插入指定位置。官方全场景能力介绍

前者减少发起传输时的寻找与选择,后者进一步描述素材该进入哪里。两者相连,但“文件传成功”与“内容放到了预期位置”仍是两件可以分别观察的事。

放到创作任务里,这种差别很具体:照片先进入图库,用户随后挑选使用,是一种路径;接收应用根据目标位置,把照片加入当前素材区或编辑位置,是另一种路径。产品要根据接收环境组织后续动作,而不是只在发送端增加一个按钮。

触碰也不意味着可以忽略选择和确认。发出哪几张、接收的是哪批、放错后能不能撤销,都关系到用户能否放心地继续手上的任务。本例因此把选图、准备分享、系统确认和接收结果分开展示。

二、先看两台手机之间,照片怎样真正交到对方手里

“拾页”从六张本机示例照片中选择要发送的内容。点选后明确显示“已选”,再点“准备碰一碰分享”。如果改选,旧监听先取消,再按新的选择准备,避免界面看着是一组,真正发出的却是上一组。

选图、改选与准备碰一碰

接着保持两台手机解锁,将顶部轻碰,发送端按系统提示发送,接收端核对来源并确认。下面的动图由双端真实截图组织,手势用于示意交互动作。

碰一碰发送与接收流程:双端真实截图,手势示意

这一段值得观察的不是“碰”本身,而是它怎样接回可理解的系统过程:用户先选内容,触碰表达交接意图,随后通过发送与接收确认,把这次交接完成。

本例记录了 Mate 60 Pro → nova 14 Ultra 的照片分享端到端结果,两台 API 等级均为26。接收后在图库“华为分享”相册查看,继续切换两张照片、打开详细信息核对。

查看已收到的图片、切换照片并打开详细信息

收到的文件图片尺寸图库显示大小保存位置
街角建筑.jpg1280×720381KB图库/华为分享/街角建筑.jpg
城市夜景.jpg1350×1800695KB图库/华为分享/城市夜景.jpg

两张图片均已保存,文件名、尺寸和路径逐项核对完成。这些结果把“发送端出现成功提示”推进到“接收端确实拿到了可查看的照片”。

三、照片到了以后,创作任务还需要一个落点

如果接下来要写游记,照片还会经历挑封面、选正文配图和暂存待整理素材。传输结束只是到了一个新的起点,接收应用仍需决定怎样把这批内容呈现出来。

“拾页”的分区接入设计用了三个目标:“待整理”“封面候选”“正文配图”。当系统提供有效触碰坐标时,应用可把它与分区在屏幕中的矩形匹配,得到建议目标;没有有效坐标时进入“待整理”,再由用户确认。

这对应精准分享更有产品意味的一层:交接的不只有文件,还有当时指向的目标。接收应用可以把这个意图转成素材分组、编辑位置等业务动作。不过坐标不能替代完整校验,分区也不应该在文件尚未确认可用时提前增加“已收到”的数量。

因此,本例把接收分成传输、文件核验、待确认和正式放入四步。建议目标可修改,也能取消;撤销上一次落位移除素材板记录,文件仍保留在应用沙箱。这样,一次手势提供了方向,用户仍保有调整的余地。

四、实操先从发送侧开始,绑定当前选择

从文末 GitCode 仓库导入工程,按 README 完成个人签名和设备调试。BoardSession.ets 管理监听与共享状态,pages/Index.ets 管理选图和素材板页面,BoardModel.ets 管理分区、待确认批次与撤销。

1. 初始化主窗口,再开放分享入口

DevEco Studio 打开仓库根目录,同步依赖并配置两台设备的个人调试签名;连接、解锁并授权 USB 调试后,选择目标设备安装 entry。发送端先选两张照片、点击“准备碰一碰分享”,再进行顶部轻碰与系统确认。

entry/src/main/ets/entryability/EntryAbility.ets 先初始化会话,再传入主窗口,以便接收侧能获得当前窗口 ID。共享存储和类型沿用仓库文件:

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { BoardSession, boardStorage } from '../BoardSession';
export default class EntryAbility extends UIAbility {
 async onWindowStageCreate(stage:window.WindowStage):Promise<void>{
  BoardSession.init(this.context);const win=await stage.getMainWindow();BoardSession.windowId=win.getWindowProperties().id;
  await stage.loadContent('pages/Index',boardStorage);
 }
 onBackground():void{BoardSession.stop();BoardSession.save();}
 onWindowStageDestroy():void{BoardSession.stop();}
}

2. 发送监听绑定当前选中的照片

发送侧监听 knockShare,系统提供可分享目标后,再把当前选中的照片组织成 SharedData。工程中的实际方法如下,位于 entry/src/main/ets/BoardSession.ets:

static armSender():void {
    BoardSession.stop();const selected=BoardSession.samples.filter(p=>BoardSession.selected.includes(p.id));
    if(!selected.length){BoardSession.status='请先选择照片';BoardSession.publish();return;}
    if(!canIUse('SystemCapability.Collaboration.HarmonyShare')){BoardSession.status='当前设备未提供碰一碰分享能力';BoardSession.publish();return;}
    BoardSession.sender=(target:harmonyShare.SharableTarget)=>{
      const data=new systemShare.SharedData({utd:'general.jpeg',uri:selected[0].uri,title:selected[0].name});
      selected.slice(1).forEach(p=>data.addRecord({utd:'general.jpeg',uri:p.uri,title:p.name}));
      BoardSession.log('knockShare','system callback; selected='+selected.length);
      target.share(data).then(()=>{BoardSession.status='已提交给系统,请在接收设备核对';BoardSession.log('share-request-resolved','Not receiver confirmation');}).catch((e:Error)=>{BoardSession.status='分享未完成,请重试';BoardSession.log('share-error',String(e));});
    };
    try{harmonyShare.on('knockShare',BoardSession.sender);BoardSession.listening='send';BoardSession.status='已准备 '+selected.length+' 张,手机顶部轻碰后按系统提示发送';BoardSession.log('send-listener','registered');}
    catch(e){BoardSession.status='碰一碰暂不可用';BoardSession.log('send-listener-error',String(e));}
  }

这里先冻结当前选择,再注册回调。target.share(data) 的 Promise 返回表示这次调用的进展,不能替代接收端保存确认。页面提示也因此写成“已提交给系统,请在接收设备核对”,最终结果仍要回到接收设备查看。

停止等待、改选或应用进入后台时,需要用原来的回调对象注销监听,避免重复订阅与发送旧内容。让按钮、当前选择和实际回调一致,才是这一段接入最需要保证的事情。

在 BoardSession.ets 中,改选与停止共用下面的方法。原回调对象用于注销监听,改选后页面重新显示当前已选数量,再由用户重新准备。

static select(id:string):void {BoardSession.stop();BoardSession.selected=BoardSession.selected.includes(id)?BoardSession.selected.filter(v=>v!==id):BoardSession.selected.concat([id]);BoardSession.status=BoardSession.selected.length+' 张已选';BoardSession.publish();}
  static stop():void {
    try{if(BoardSession.sender){harmonyShare.off('knockShare',BoardSession.sender);}if(BoardSession.receiver&&BoardSession.recvRegistry){harmonyShare.off('dataReceive',BoardSession.recvRegistry,BoardSession.receiver);}}
    catch(e){BoardSession.log('unsubscribe-error',String(e));}
    BoardSession.sender=undefined;BoardSession.receiver=undefined;BoardSession.listening='';BoardSession.publish();
  }

在两台手机上实际操作时,发送端确认的是当前选中的“街角建筑”和“城市夜景”;接收后在 nova 图库“华为分享”相册逐张打开详细信息,核对文件名、尺寸、大小与保存位置。

五、接收侧把窗口和坐标转成业务目标

应用先从主窗口取得 windowId,再按接收类型与数量上限注册 dataReceive。这一层告诉系统哪个窗口可以接收什么内容;注册成功本身不是文件传输完成的证据。

static armReceiver():void {
    BoardSession.stop();if(BoardSession.windowId<0){BoardSession.status='窗口尚未就绪';BoardSession.publish();return;}
    const registry:harmonyShare.RecvCapabilityRegistry={windowId:BoardSession.windowId,capabilities:[{utd:'general.image',maxSupportedCount:9}]};
    BoardSession.receiver=(target:harmonyShare.ReceivableTarget)=>{BoardSession.receive(target).catch((e:Error)=>{BoardSession.status='接收未完成,请检查后重试';BoardSession.log('receive-unexpected-error',String(e));});};BoardSession.recvRegistry=registry;
    try{harmonyShare.on('dataReceive',registry,BoardSession.receiver);BoardSession.listening='receive';BoardSession.status='接收监听已开启,等待系统传入图片';BoardSession.log('receive-listener','registered; registration is not transfer proof');}
    catch(e){BoardSession.status='当前设备无法开启应用内接收';BoardSession.log('receive-listener-error',String(e));}
  }

收到目标后,接收代码读取 target.getInfo().coordinate。页面通过 getRectangleById 获取分区的屏幕位置与尺寸,让触碰点和分区使用同一坐标系。下面是 BoardModel.ets 中的实际匹配逻辑:

export function mapZone(point: ScreenPoint | undefined, rects: ZoneRect[]): number {
  if (!point || !Number.isInteger(point.x) || !Number.isInteger(point.y) || point.x < 0 || point.y < 0) { return 0; }
  const hits = rects.filter(r => Number.isFinite(r.x) && Number.isFinite(r.y) && r.width > 0 && r.height > 0 && point.x >= r.x && point.x < r.x + r.width && point.y >= r.y && point.y < r.y + r.height);
  return hits.length === 1 ? hits[0].zone : 0;
}

左上包含、右下不包含的边界规则可以减少相邻区域同时命中的问题;坐标缺失、越界或命中不唯一时,回到待整理区。对用户来说,结果是一个仍可调整的建议目标;对代码来说,它是明确而可测试的规则。

每批接收再创建独立目录和 token,把系统回调、文件列表与待确认状态关联起来。文件到达和结果回调不假设固定先后,只有成功结果与有效文件都具备时,才进入可确认状态。

接收批次:先拿到文件,再生成待确认素材

接收代码同样位于 BoardSession.ets。下列方法将系统结果、接收目录、坐标、文件列表与 token 绑定到一批数据;validateReceived() 检查 URI、目录、实际文件与解码尺寸,再把有效文件交给模型。这里展示的是精准落位接入设计,前面的双手机实测结果以接收图库中的两张文件为准。

private static async receive(target:harmonyShare.ReceivableTarget):Promise<void> {
    if(BoardSession.seenTargets.includes(target)){BoardSession.log('duplicate-target','same callback object ignored');return;}
    if(BoardSession.model.pending){await target.reject(harmonyShare.ReceivableErrorCode.NO_RECEIVABLE_ERROR);return;}
    BoardSession.seenTargets.push(target);BoardSession.seenTargets=BoardSession.seenTargets.slice(-32);
    const token='rx-'+Date.now()+'-'+(++BoardSession.serial);
    try {
    const c=target.getInfo().coordinate;
    const point:ScreenPoint|undefined=c?{x:c.screenX,y:c.screenY}:undefined;
    const rects=BoardSession.readRects();const targetZone=mapZone(point,rects);
    BoardSession.model.begin(token,point,rects);
    const directory=BoardSession.context.filesDir+'/'+token;fs.mkdirSync(directory);
    BoardSession.status='正在接收,目标:'+ZONES[targetZone];BoardSession.log('dataReceive',JSON.stringify(c??null));
      await target.receive(fileUri.getUriFromPath(directory),{
        onDataReceived:(data:systemShare.SharedData)=>{BoardSession.validateReceived(token,directory,data);},
        onResult:(result:harmonyShare.ShareResultCode)=>{BoardSession.model.result(token,result===harmonyShare.ShareResultCode.SHARE_SUCCESS);BoardSession.status=result===0?'文件已传输,等待接收数据核验':'接收未完成,可关闭后重新碰一碰';BoardSession.log('receive-result',String(result));BoardSession.updateReceipt();}
      });
      BoardSession.log('receive-request-resolved','Awaiting onDataReceived and onResult');
    }catch(e){BoardSession.model.result(token,false);BoardSession.status='接收失败,请重试';BoardSession.log('receive-error',String(e));}
  }
private static async validateReceived(token:string,directory:string,data:systemShare.SharedData):Promise<void> {
    try{
      const records=data.getRecords();if(!records.length||records.length>9){throw new Error('Invalid image count');}
      const items:BoardItem[]=[];let total=0;
      for(let i=0;i<records.length;i++){
        const uri=records[i].uri;if(!uri){throw new Error('Missing URI');}const path=new fileUri.FileUri(uri).path;
        if(!path.startsWith(directory+'/')||path.split('/').includes('..')){throw new Error('Unexpected sandbox file');}
        const stat=fs.statSync(path);total+=stat.size;if(!stat.isFile()||stat.size<=0||stat.size>32*1024*1024||total>128*1024*1024){throw new Error('File size out of range');}
        const f=fs.openSync(path,fs.OpenMode.READ_ONLY);const source=image.createImageSource(f.fd);
        try{const info=await source.getImageInfo();if(info.size.width<=0||info.size.height<=0){throw new Error('Invalid image');}}finally{await source.release();fs.closeSync(f);}
        items.push({id:token+'-'+i,name:path.substring(path.lastIndexOf('/')+1),uri:uri,bytes:stat.size,zone:0,batch:token});
      }
      BoardSession.model.received(token,items);BoardSession.log('received-files',items.map(i=>i.name+':'+i.bytes).join(';'));BoardSession.updateReceipt();
    }catch(e){BoardSession.model.result(token,false);BoardSession.status='图片核验失败,本批未加入素材板';BoardSession.log('file-validation-error',String(e));}
  }

模型层再统一处理建议分区、用户确认和撤销。收到文件后先进入待确认状态,确认才计入分区,撤销移除上一批素材板记录。仓库提供坐标边界、重复回调、同名文件与批次状态用例,可按 README 运行 node test-model.cjs。

完整初始化、文件校验、回调顺序处理、确认与撤销代码,见配套工程与 README。仓库的 test-model.cjs 提供坐标边界、重复回调、同名文件和批次状态等用例,运行方式见 README。

六、交接完成的标准,应该在对端继续检查

双手机分享的实测覆盖了选图、触碰后发送确认、接收确认、图库保存与文件信息核对。接收端的两张详情截图,也保留了这次结果的具体落点。

接收后街景建筑的图库详情

接收后城市夜景的图库详情

对于分区业务,代码还要继续回答:当前文件属于哪一批,是否可读取,坐标指向哪个区域,用户确认后有没有重复加入,撤销移除的是不是上一批。让这些问题有明确答案,才能避免“传输成功”与“内容可用”之间出现空档。

七、手势缩短交接,应用负责让任务继续

回到一起整理照片的情境,用户真正想做的是把这两张图交给对方继续使用。碰一碰让发起动作更接近现实中的递送,精准落位则让应用有机会把“接到哪里”的意图也接进流程。

值得设计的后续,可能是把照片放入草稿、加入素材区,或者先展示一批可确认的附件。不同业务的目标不一样,但都需要清楚的内容选择、对端结果和修正入口。交接轻了,任务才更容易顺着刚才的动作继续下去。

配套工程与资料

GitCode:拾页碰一碰照片分享 Demo。README 提供导入、签名、设备操作、分区接收说明与模型测试步骤;照片来源和许可随工程保留。

HarmonyOS 全场景能力介绍

Logo

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

更多推荐