HarmonyOS 精准分享图片选择与素材封装(连载02)【共创稿事节】
HarmonyOS 精准分享图片选择与素材封装(连载02)【共创稿事节】
上一节将目标窗口、文档修订号与触碰落点收束进一次可核验的投递会话。这一节处理另一半问题:手机上选中的现场照片究竟怎样进入素材清单,怎样避免一张图片因重复点击被添加两次,以及怎样将临时 URI 与接收端需要的业务信息分开。对于在平板图纸中插入现场照片的场景,目标校验通过仍不足以提交;接收端还需要能够访问且格式正确的素材。

一、先选图片,再建立业务素材主键
HarmonyOS 的 Media Library Kit 提供系统图片选择器。应用可以通过 PhotoViewPicker 让用户自主选择图片,并从返回值取得 photoUris。选择器返回的是当前访问链路中的 URI,不应把该 URI 当成后端数据库里的永久素材主键:内容读取权限、文件当前位置和接收端可访问范围都需要按各自的分享方式处理。
本文的手机页面保持三个区域:顶部是“继续选取”入口,中间用缩略图展示待发图片,底部显示投递数量与下一步操作。选择完成后先生成业务 assetId,再关联 URI。用户重复选择同一 URI 时执行去重,取消选择则移除对应行,不误删其他图片的临时记录。
// entry/src/main/ets/common/AssetModel.ets
export type AssetStatus = 'selected' | 'prepared' | 'sent' | 'failed';
export interface SelectedAsset {
assetId: string;
sourceUri: string;
displayName: string;
order: number;
status: AssetStatus;
}
export interface AssetBatch {
batchId: string;
createdAt: number;
targetWindowId: string;
targetDocumentId: string;
assets: SelectedAsset[];
}
export function addUniqueAssets(
current: SelectedAsset[], incomingUris: string[], now: number
): SelectedAsset[] {
const result: SelectedAsset[] = current.slice();
incomingUris.forEach((uri: string, index: number) => {
if (uri.length === 0 || result.some((item: SelectedAsset) =>
item.sourceUri === uri)) return;
result.push({
assetId: `asset-${now}-${index}`,
sourceUri: uri,
displayName: `现场照片 ${result.length + 1}`,
order: result.length,
status: 'selected'
});
});
return result;
}
这里用时间戳加索引生成前端临时标识,作用范围是当前批次。在跨设备存储、并发重试或离线恢复的业务中,服务端应分配真正具备全局唯一性的素材标识。sourceUri 只服务于当前选取与读取环节,业务状态字段只表示应用对素材准备进度的认识。
二、接入 PhotoViewPicker 并管理状态
选择器调用放在用户点击事件内,避免页面自动弹出系统选取界面。选择最多六张图片,可以让列表布局、上传耗时和错误提示都更容易控制。若用户直接返回而没有选择,保持原有清单;发生选取异常时保留旧内容,并给出明确的操作反馈。
// entry/src/main/ets/pages/AssetPickerPage.ets
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { addUniqueAssets, SelectedAsset } from '../common/AssetModel';
@Entry
@Component
struct AssetPickerPage {
@State images: SelectedAsset[] = [];
@State hint: string = '请选择需要投递的现场照片';
@State selecting: boolean = false;
private async pickImages(): Promise<void> {
if (this.selecting) return;
this.selecting = true;
try {
const picker = new photoAccessHelper.PhotoViewPicker();
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 6;
const result = await picker.select(options);
const uris: string[] = result.photoUris ?? [];
this.images = addUniqueAssets(this.images, uris, Date.now());
this.hint = this.images.length > 0 ?
`已选${this.images.length}张,可继续核对目标` : '尚未选择图片';
} catch (error) {
this.hint = `选取图片遇到问题:${JSON.stringify(error)}`;
} finally {
this.selecting = false;
}
}
build() {
Column({ space: 12 }) {
Text('现场素材').fontSize(26).fontWeight(FontWeight.Bold)
Text(this.hint).fontColor('#64748B').fontSize(14)
Button(this.selecting ? '正在选择' : '从相册选择')
.enabled(!this.selecting)
.onClick(() => { this.pickImages(); })
List({ space: 10 }) {
ForEach(this.images, (item: SelectedAsset) => {
ListItem() {
Row({ space: 12 }) {
Image(item.sourceUri)
.width(72).height(72).objectFit(ImageFit.Cover)
.borderRadius(12)
Column({ space: 6 }) {
Text(item.displayName).fontSize(16)
Text(item.status).fontSize(12).fontColor('#64748B')
}.alignItems(HorizontalAlign.Start)
}.width('100%')
}
}, (item: SelectedAsset) => item.assetId)
}.layoutWeight(1)
Button(`核对投递目标 · ${this.images.length}张`)
.enabled(this.images.length > 0)
}.padding(20).width('100%').height('100%')
}
}
Image(uri) 用于页面预览;它只负责渲染,不能据此判定原始图片尺寸、文件大小或接收端访问权限。实际工程中,清单数量发生变化时还应更新总大小估算和上传策略。如果需要长期保留资源,应根据文件来源及使用期限将素材复制到应用控制的目录,并按媒体访问规范处理。

三、为投递建立完整的批次描述
接收端需要知道“投递哪些素材”和“向哪里插入”,但这两组数据的生命周期不同。图像 URI 可随选取会话变化,目标窗口可随文档切换变化。最清晰的做法是在用户确认后创建不可随意改写的批次快照:包含批次标识、目标窗口与文档、素材顺序和各素材业务主键。
下面的函数复制字段而不改写原始列表。通过 status 控制素材准备结果,下一阶段再把接收端返回的条目结果合入清单。复制结构可以减少异步处理中“选中第二张图后,第一张图也被改写”的问题。
// entry/src/main/ets/common/BatchBuilder.ets
import { AssetBatch, SelectedAsset } from './AssetModel';
export function createBatch(
windowId: string, documentId: string,
selected: SelectedAsset[], timestamp: number
): AssetBatch {
const items: SelectedAsset[] = selected
.filter((item: SelectedAsset) => item.sourceUri.length > 0)
.map((item: SelectedAsset, index: number) => ({
assetId: item.assetId,
sourceUri: item.sourceUri,
displayName: item.displayName,
order: index,
status: 'selected'
} as SelectedAsset));
return {
batchId: `batch-${timestamp}`,
createdAt: timestamp,
targetWindowId: windowId,
targetDocumentId: documentId,
assets: items
};
}
export function canPrepare(batch: AssetBatch): boolean {
if (!batch.targetWindowId || !batch.targetDocumentId) return false;
if (batch.assets.length < 1 || batch.assets.length > 12) return false;
return batch.assets.every((asset: SelectedAsset) =>
asset.sourceUri.length > 0 && asset.assetId.length > 0);
}
真实落点应与上一节的 TargetEnvelope 关联,而非作为永久文本说明写在素材上。假如用户先选六张图、又关闭目标文档,图片列表仍可继续使用,只需重新取得窗口信息并生成新的目标会话。相反,如果用户在确认页移除一张图,就应该重新创建批次快照,防止接收端的素材数量与界面上的数量不一致。
四、普通分享与精准插入的两条路径
系统 Share Kit 已支持文本、图片等跨应用分享场景。其文件分享方式应按 Share Kit 对 URI、数据类型及权限的要求构造,避免将本机私有路径直接作为其他应用可读取的地址。本文选取到的 photoUris 首先进入应用素材清单,之后再由传输适配层转成对应分享载荷。
对于普通分享,用户通过系统分享面板选取目标应用;对于目标窗口明确且需插入画布坐标的精准分享,接收端还应校验 windowId、documentId、触点位置和会话修订号,才能在目标业务服务中写入图片。不能将“选择器返回 URI”“系统分享面板打开”“素材已插入指定画布”看作同一个状态。
协议层建议至少区分以下回执:ACCEPTED 表示目标受理,ASSET_READY 表示读取完成,INSERTED 表示写入目标文档,REJECTED 表示拒绝接收。显示在用户界面上的成功标记只依据 INSERTED,避免传输发起后误导用户。

五、处理 URI 过期、取消与重复选择
图片选择器的返回结果需要尽快消费,尤其是在应用切到后台、跨应用跳转、内存重建之后。业务层应区分“用户取消选择”和“文件读取失败”,二者在体验上完全不同。前者保持原列表,后者允许针对受影响的条目重新选择或重新准备。若图片已经从相册删除,清单中的旧 URI 也应失效,并要求用户确认新素材。
| 情况 | 判断点 | 对应动作 |
|---|---|---|
| 选取结果为空 | photoUris.length | 保留原有素材清单 |
| 重复选中同一图片 | sourceUri 已存在 | 保持一条记录,不改变顺序 |
| 列表项无法预览 | 当前 URI 的读取结果 | 标记准备失败并允许重选 |
| 目标窗口切换 | 新旧文档和修订号 | 保留图片,重新校验目标 |
| 接收端部分成功 | 每项 assetId 回执 | 按条目更新状态,避免整批误判 |
| 用户离开选择页面 | 页面生命周期与待处理任务 | 取消过期操作,释放暂存资源 |
调试重点可以安排成三组。第一组多次选择相同照片,确认列表不出现重复项;第二组先选照片再改变目标,确认旧落点不能继续使用;第三组人为造成某张素材不可读取,确认其他素材仍可显示、失败项具备明确状态。日志以批次与素材业务标识串联,不记录图片内容本身。需要按文件内容去重时,应在取得合法可读数据后再计算摘要,不能直接用 URI 字符串代替内容哈希。
六、准备下一步的落点坐标映射
到这里,系列工程已经拥有两个独立且可组合的能力:一套目标会话状态机,一套图片选择与业务素材批次。下一阶段将解决更细的定位问题:平板窗口有标题栏、工具区和可缩放画布,触碰发生的位置属于哪套坐标系,才能决定照片落到图纸上的哪一个点。只有把缩放比例、平移偏移和修订号统一到坐标转换中,才能完成真正可控的定向插入。
参考文档:
更多推荐
所有评论(0)