Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
37 KiB
分享重构(去 SDK 化 + 剪贴板指引 + 系统分享兜底)Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把微信/QQ/抖音分享统一改为「剪贴板+指引窗 → 系统分享」,不依赖任何分享 SDK,内容对齐原工程(文本统一 title\ndescription、图片不降级),契约/DTO 零改动。
Architecture: 沿用现有"能力层发事件 → entry 容器弹 UI → 一次性 resultEvent 回投"模式,新增第二个弹窗 ShareGuideDialog(渠道选定后选动作:打开App/用系统分享/取消)。ShareProvider 重写为:组装内容→写剪贴板→弹指引窗→按动作执行 openLink(best-effort) / systemShare(可靠兜底),仅微信乐观回传 sharesuccess。
Tech Stack: HarmonyOS ArkTS / ArkUI;@ohos.pasteboard(文本/PIXELMAP)、@kit.ShareKit systemShare、@kit.AbilityKit bundleManager.canOpenLink/openLink、@kit.ImageKit createImageSource、平台层 Downloader。
约定:
- 每个任务的硬验证 =
devecocli build输出BUILD SUCCESSFUL(本仓库devecocli无 test 子命令,单元测试只能在 DevEco Studio 内运行,故 CLI 阶段以 build 编译通过为门禁,行为类验证靠真机/模拟器)。 - ArkTS 严格模式:禁
Record/无类型对象字面量、跨层只依赖接口、@Concurrent不传函数。 - 每个任务结束即
git commit(中文信息,注明对应契约/计划条目)。
File Structure
| 文件 | 责任 | 动作 |
|---|---|---|
common/src/main/ets/event/ShareEvents.ets |
分享事件名 + 载荷 DTO | 改:加 SHOW_GUIDE + ShareGuideRequest/ShareGuideResult |
common/Index.ets |
common 公共导出 | 改:导出新增两个 interface |
entry/src/main/module.json5 |
模块配置 | 改:querySchemes 加 mqqapi/snssdk1128 |
feature_capabilities/src/main/ets/share/ShareText.ets |
分享文本拼接纯函数(可单测) | 新建 |
feature_capabilities/Index.ets |
模块导出 | 改:导出 buildShareText(供单测) |
feature_capabilities/src/main/ets/wx/WeChatApi.ets |
微信 SDK 封装 | 改:删分享路径,仅留登录授权 |
feature_capabilities/src/main/ets/providers/ShareProvider.ets |
分享能力 | 重写(整文件替换) |
entry/src/main/ets/components/ShareGuideDialog.ets |
指引弹窗 UI | 新建 |
entry/src/main/ets/pages/BridgeGameContainer.ets |
容器接线 | 改:订阅/渲染 ShareGuideDialog |
entry/src/ohosTest/.../ShareText.test.ets |
buildShareText 单测 | 新建 |
Task 1: common 新增指引窗事件与载荷
Files:
-
Modify:
common/src/main/ets/event/ShareEvents.ets -
Modify:
common/Index.ets -
Step 1: 在
ShareEvents类内新增SHOW_GUIDE事件名
编辑 common/src/main/ets/event/ShareEvents.ets,在 PHOTO_UPLOAD 常量后追加:
/** 请求弹出分享指引窗(渠道选定后,由 ShareProvider 发,BridgeGameContainer 弹窗)。 */
static readonly SHOW_GUIDE: string = 'share.showGuide';
- Step 2: 在文件末尾追加两个载荷 interface
/** SHOW_GUIDE 载荷。 */
export interface ShareGuideRequest {
/** 渠道:'wechat' | 'qq' | 'douyin'。 */
platform: string;
/** 内容类型:'text' | 'image'(决定指引文案与系统分享走 systemText/systemImage)。 */
contentKind: string;
/** 用户选定动作后回投的一次性事件名(ShareProvider 已 EventBus.once 监听)。 */
resultEvent: string;
}
/** 指引窗动作回投载荷。 */
export interface ShareGuideResult {
/** 'open'(打开App)| 'system'(用系统分享)| 'cancel'。 */
action: string;
}
- Step 3: 在
common/Index.ets导出新 interface
把第 9 行的导出改为(追加 ShareGuideRequest, ShareGuideResult):
export { ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, PhotoUploadPayload }
from './src/main/ets/event/ShareEvents';
- Step 4: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 5: 提交
git add common/src/main/ets/event/ShareEvents.ets common/Index.ets
git commit -m "feat(share): common 新增 SHOW_GUIDE 事件与 ShareGuide 载荷(分享重构 §4)"
Task 2: module.json5 声明 QQ/抖音 querySchemes
Files:
-
Modify:
entry/src/main/module.json5:13-15 -
Step 1: 扩充 querySchemes
把:
"querySchemes": [
"weixin"
],
改为:
"querySchemes": [
"weixin",
"mqqapi",
"snssdk1128"
],
- Step 2: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 3: 提交
git add entry/src/main/module.json5
git commit -m "feat(share): querySchemes 增加 mqqapi/snssdk1128 供指引窗拉起QQ/抖音(分享重构 §7.4)"
Task 3: 分享文本拼接纯函数(可单测)
Files:
-
Create:
feature_capabilities/src/main/ets/share/ShareText.ets -
Modify:
feature_capabilities/Index.ets -
Step 1: 新建
ShareText.ets
/**
* 分享文本拼接(纯函数,可单测)。
* 规则(对齐原工程 + 用户确认):`title\ndescription`,**单换行、三端一致、不含 url**;空字段跳过。
*/
export function buildShareText(title: string, description: string): string {
const parts: string[] = [];
if (title !== '') {
parts.push(title);
}
if (description !== '') {
parts.push(description);
}
return parts.join('\n');
}
- Step 2: 在
feature_capabilities/Index.ets末尾导出(供单测)
export { buildShareText } from './src/main/ets/share/ShareText';
- Step 3: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 4: 提交
git add feature_capabilities/src/main/ets/share/ShareText.ets feature_capabilities/Index.ets
git commit -m "feat(share): 抽出 buildShareText 纯函数 title\\ndescription 单换行无url(分享重构 §5.1)"
Task 4: WeChatApi 去除分享路径(仅留登录授权)
Files:
- Modify:
feature_capabilities/src/main/ets/wx/WeChatApi.ets(整文件替换)
背景:去 SDK 仅针对分享;微信登录仍用
@tencent/wechat_open_sdk,依赖必须保留。本任务删掉sendShare/ShareRespCallback/SendMessageToWXResp分支。
- Step 1: 整文件替换为
/**
* 微信开放平台 SDK 封装(@tencent/wechat_open_sdk)。单例。**仅用于登录授权**(分享已去 SDK 化)。
*
* 职责:持有 WXApi(用 AppID 创建)、发起授权请求、接收微信回调并路由到 authCb。
* AppID 为公开标识,可入客户端;AppSecret 绝不入端(登录换 profile 须服务端,§13 红线)。
*/
import { common, Want } from '@kit.AbilityKit';
import {
WXAPIFactory, WXApi, WXApiEventHandler, BaseReq, BaseResp, SendAuthResp,
} from '@tencent/wechat_open_sdk';
import { Logger } from 'common';
/** 微信 AppID(公开标识,契约 §13)。 */
export const WX_APP_ID: string = 'wxd2bd650e06bdfe58';
export type AuthRespCallback = (resp: SendAuthResp) => void;
export class WeChatApi {
private static readonly log: Logger = Logger.tag('WeChatApi');
private static inst: WeChatApi | undefined = undefined;
private readonly api: WXApi;
private readonly handler: WXApiEventHandler;
private authCb: AuthRespCallback | undefined = undefined;
private constructor() {
this.api = WXAPIFactory.createWXAPI(WX_APP_ID);
this.handler = {
onReq: (_req: BaseReq) => { },
onResp: (resp: BaseResp) => this.routeResp(resp),
};
}
static getInstance(): WeChatApi {
if (WeChatApi.inst === undefined) {
WeChatApi.inst = new WeChatApi();
}
return WeChatApi.inst;
}
isWXInstalled(): boolean {
try {
return this.api.isWXAppInstalled();
} catch (e) {
WeChatApi.log.w(`isWXAppInstalled failed: ${(e as Error).message}`);
return false;
}
}
/** 发起授权登录;resp 经 cb 异步回调。 */
sendAuth(context: common.UIAbilityContext, req: BaseReq, cb: AuthRespCallback): void {
this.authCb = cb;
this.api.sendReq(context, req);
}
/** 由 EntryAbility 在 onNewWant/onCreate 调用,处理微信回调 want。 */
handleWant(want: Want): void {
try {
this.api.handleWant(want, this.handler);
} catch (e) {
WeChatApi.log.w(`handleWant failed: ${(e as Error).message}`);
}
}
private routeResp(resp: BaseResp): void {
if (resp instanceof SendAuthResp) {
const cb = this.authCb;
this.authCb = undefined;
if (cb !== undefined) {
cb(resp);
}
}
}
}
- Step 2: 编译验证
Run: devecocli build
Expected: BUILD FAILED(因 ShareProvider.ets 仍 import WeChatApi.sendShare 等——预期,下一任务修复)
若想保持每步 build 绿,可与 Task 5 合并提交;这里单独列出便于审查。执行 subagent 可先做 Task 5 的整文件替换再一起 build。
- Step 3:(暂不单独提交,与 Task 5 一起验证后提交)
跳到 Task 5。
Task 5: 重写 ShareProvider(核心)
Files:
-
Modify:
feature_capabilities/src/main/ets/providers/ShareProvider.ets(整文件替换) -
Step 1: 整文件替换为
/**
* 分享能力(契约 §8.1/§10.1)。三端统一「剪贴板+指引窗 → 系统分享」,**不依赖任何分享 SDK**。
*
* H5 调 friendsSharetypeUrlToptitleDescript(data=sharetypeBean JSON):
* - sharefriend=="2" → 直接微信指引窗(朋友圈语义,回传 type=2)。
* - sharefriend=="1" → 弹自定义面板(微信/QQ/抖音/取消) → 选定渠道。
* 选定渠道后按内容类型组装并写剪贴板,再弹 ShareGuideDialog:
* - 文本(type1/4/其他):剪贴板 PLAIN_TEXT(title\ndescription,无 url);
* - 图片(type2 截图 / type3 图片链接下载,低优先):剪贴板 PIXELMAP(尽力)。
* 指引窗动作:打开App(openLink scheme,best-effort) / 用系统分享(systemText|systemImage,可靠) / 取消。
* 仅微信乐观回传 sharesuccess:打开/系统分享→{success:2,type};取消→{success:3,type}。QQ/抖音不回传。
*
* 注:图片"复制剪贴板再去对方App粘贴"能否成功取决于对方是否读 PIXELMAP(鸿蒙不可保证),
* 故指引窗"用系统分享"(systemImage) 为可靠兜底;拉起 App 仅打开、不预填(需用户自行粘贴)。
*/
import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { fileUri } from '@kit.CoreFileKit';
import { common, bundleManager } from '@kit.AbilityKit';
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
import { image } from '@kit.ImageKit';
import { util } from '@kit.ArkTS';
import { BridgeController } from 'feature_bridge';
import { InboundHandlers, OutboundHandlers, SharetypeBean, ShareSuccessResp } from 'contracts';
import {
EventBus, Logger, ShareEvents,
SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, PhotoUploadPayload,
} from 'common';
import { FileSystem, Downloader } from 'platform';
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
import { buildShareText } from '../share/ShareText';
export class ShareProvider implements CapabilityProvider {
/** 进程内实例计数:双 Web 槽各一个实例,用它给 resultEvent 命名空间隔离。 */
private static instanceCount: number = 0;
readonly name: string = 'share';
private readonly log: Logger = Logger.tag('ShareProvider');
private readonly instanceId: number;
private bridge: BridgeController | undefined = undefined;
private ctx: CapabilityContext | undefined = undefined;
/** 落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */
private seq: number = 0;
private photoUploadCancel: (() => void) | undefined = undefined;
constructor() {
ShareProvider.instanceCount += 1;
this.instanceId = ShareProvider.instanceCount;
}
register(bridge: BridgeController, ctx: CapabilityContext): void {
this.bridge = bridge;
this.ctx = ctx;
bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => {
this.onShare(data);
});
// 第二条链路:H5 主动 POST 截图到 LocalUploadServer → emit PHOTO_UPLOAD → 微信图片指引窗。
this.photoUploadCancel = EventBus.on(ShareEvents.PHOTO_UPLOAD, (p) => this.onPhotoUpload(p));
}
onDestroy(): void {
if (this.photoUploadCancel !== undefined) {
this.photoUploadCancel();
this.photoUploadCancel = undefined;
}
}
// —— 入站 ——
private onShare(data: string): void {
if (this.ctx === undefined) {
return;
}
let bean: SharetypeBean;
try {
bean = JSON.parse(data) as SharetypeBean;
} catch (e) {
this.log.w(`share bad json: ${(e as Error).message}`);
return;
}
if (bean === undefined || bean === null) {
this.log.w('share bean empty');
return;
}
if (bean.sharefriend === '2') {
// 朋友圈语义:不弹面板,直接微信指引窗(契约 §10.1)。
this.startShare(bean, 'wechat');
return;
}
this.seq += 1;
const resultEvent: string = `share.result.${this.instanceId}.${this.seq}`;
EventBus.once(resultEvent, (p) => this.onPlatformChosen(bean, p));
const req: SharePanelRequest = { data, resultEvent };
EventBus.emit(ShareEvents.SHOW_PANEL, req);
}
private onPlatformChosen(bean: SharetypeBean, payload?: Object): void {
const platform: string = payload !== undefined ? (payload as SharePanelResult).platform : 'cancel';
if (platform === 'wechat' || platform === 'qq' || platform === 'douyin') {
this.startShare(bean, platform);
}
// cancel:不分享、不回传。
}
/** H5 POST 截图上传:仅激活槽处理,落盘后走微信图片指引窗。 */
private onPhotoUpload(payload?: Object): void {
const ctx = this.ctx;
const bridge = this.bridge;
if (ctx === undefined || bridge === undefined || !bridge.isActive() || payload === undefined) {
return;
}
const up = payload as PhotoUploadPayload;
if (up.imageBase64 === '') {
this.log.w('photo upload empty image');
return;
}
const bean: SharetypeBean = {
sharefriend: up.type, type: '2', sharetype: '', webpageUrl: '', title: '', description: '',
};
const filePath: string = this.saveDataUrlToFile(ctx.uiAbilityContext, up.imageBase64);
if (filePath === '') {
this.log.w('photo upload save failed; report cancel');
this.reportResult(3, up.type === '1' ? 1 : 2);
return;
}
this.onImageReady(bean, 'wechat', filePath);
}
// —— 组装 + 弹指引窗 ——
private startShare(bean: SharetypeBean, platform: string): void {
if (bean.type === '2') {
this.startCanvasImageShare(bean, platform);
} else if (bean.type === '3') {
this.startLinkImageShare(bean, platform);
} else {
this.startTextShare(bean, platform);
}
}
private startTextShare(bean: SharetypeBean, platform: string): void {
this.writeClipboardText(buildShareText(bean.title, bean.description));
this.guide(bean, platform, 'text', '');
}
/** type2:截当前 Web canvas → 落盘 → 图片指引窗(捕获失败降级文本指引,非"图片转文本"语义)。 */
private startCanvasImageShare(bean: SharetypeBean, platform: string): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
}
const cap = ctx.captureCanvas;
if (cap === undefined) {
this.startTextShare(bean, platform);
return;
}
cap(bean.sharetype).then((dataUrl: string) => {
const filePath: string = dataUrl !== '' ? this.saveDataUrlToFile(ctx.uiAbilityContext, dataUrl) : '';
if (filePath !== '') {
this.onImageReady(bean, platform, filePath);
} else {
this.log.w('canvas capture empty; fallback to text guide');
this.startTextShare(bean, platform);
}
}).catch((e: Error) => {
this.log.w(`captureCanvas failed: ${e.message}; fallback to text guide`);
this.startTextShare(bean, platform);
});
}
/** type3(低优先,需确认 H5 是否使用):从 webpageUrl 下载图片 → 落盘 → 图片指引窗(失败降级文本)。 */
private startLinkImageShare(bean: SharetypeBean, platform: string): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
}
const url: string = bean.webpageUrl;
if (url === '' || !(url.startsWith('http://') || url.startsWith('https://'))) {
this.startTextShare(bean, platform);
return;
}
this.seq += 1;
const ext: string = url.toLowerCase().indexOf('.png') >= 0 ? '.png' : '.jpg';
const savePath: string = `${ctx.uiAbilityContext.filesDir}/share/link_${this.seq}${ext}`;
Downloader.download(url, savePath).then(() => {
this.onImageReady(bean, platform, savePath);
}).catch((e: Error) => {
this.log.w(`link image download failed: ${e.message}; fallback to text guide`);
this.startTextShare(bean, platform);
});
}
private onImageReady(bean: SharetypeBean, platform: string, filePath: string): void {
this.writeClipboardImage(filePath);
this.guide(bean, platform, 'image', filePath);
}
private guide(bean: SharetypeBean, platform: string, kind: string, filePath: string): void {
this.seq += 1;
const resultEvent: string = `share.guide.${this.instanceId}.${this.seq}`;
EventBus.once(resultEvent, (p) => this.onGuideAction(bean, platform, kind, filePath, p));
const req: ShareGuideRequest = { platform, contentKind: kind, resultEvent };
EventBus.emit(ShareEvents.SHOW_GUIDE, req);
}
private onGuideAction(bean: SharetypeBean, platform: string, kind: string, filePath: string, payload?: Object): void {
const ctx = this.ctx;
if (ctx === undefined) {
return;
}
const uiCtx: common.UIAbilityContext = ctx.uiAbilityContext;
const action: string = payload !== undefined ? (payload as ShareGuideResult).action : 'cancel';
const reportType: number = bean.sharefriend === '1' ? 1 : 2;
if (action === 'open') {
this.openApp(uiCtx, platform);
if (platform === 'wechat') {
this.reportResult(2, reportType);
}
} else if (action === 'system') {
if (kind === 'image' && filePath !== '') {
this.systemImage(uiCtx, filePath, bean);
} else {
this.systemText(uiCtx, bean);
}
if (platform === 'wechat') {
this.reportResult(2, reportType);
}
} else {
// cancel
if (platform === 'wechat') {
this.reportResult(3, reportType);
}
}
}
// —— 拉起 App(best-effort,仅打开不预填)——
private schemeOf(platform: string): string {
if (platform === 'qq') {
return 'mqqapi://';
}
if (platform === 'douyin') {
return 'snssdk1128://';
}
return 'weixin://';
}
private openApp(uiCtx: common.UIAbilityContext, platform: string): void {
const link: string = this.schemeOf(platform);
let canOpen: boolean = false;
try {
canOpen = bundleManager.canOpenLink(link);
} catch (e) {
this.log.w(`canOpenLink failed: ${(e as BusinessError).message}`);
return;
}
if (!canOpen) {
this.log.i(`app not openable: ${link}`);
return;
}
uiCtx.openLink(link, { appLinkingOnly: false }).catch((e: BusinessError) => {
this.log.w(`openLink failed: code=${e.code} ${e.message}`);
});
}
// —— 剪贴板 ——
private writeClipboardText(text: string): void {
try {
const data: pasteboard.PasteData =
pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text !== '' ? text : ' ');
pasteboard.getSystemPasteboard().setData(data)
.catch((e: BusinessError) => this.log.w(`clipboard text failed: ${e.code} ${e.message}`));
} catch (e) {
this.log.w(`clipboard text init failed: ${(e as Error).message}`);
}
}
/** 图片写剪贴板(尽力;对方能否粘贴 PIXELMAP 不可保证,systemImage 才是可靠交付)。 */
private writeClipboardImage(filePath: string): void {
const source: image.ImageSource = image.createImageSource(filePath);
source.createPixelMap().then((pm: image.PixelMap) => {
try {
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pm);
pasteboard.getSystemPasteboard().setData(data)
.catch((e: BusinessError) => this.log.w(`clipboard image failed: ${e.code} ${e.message}`));
} catch (e) {
this.log.w(`clipboard image init failed: ${(e as Error).message}`);
}
}).catch((e: BusinessError) => {
this.log.w(`createPixelMap failed: ${e.code} ${e.message}`);
});
}
// —— 系统分享(可靠兜底;文本不下发 url)——
private systemText(uiCtx: common.UIAbilityContext, bean: SharetypeBean): void {
const text: string = buildShareText(bean.title, bean.description);
const record: systemShare.SharedRecord = {
utd: utd.UniformDataType.PLAIN_TEXT, content: text !== '' ? text : ' ',
};
this.showSystemShare(uiCtx, record);
}
private systemImage(uiCtx: common.UIAbilityContext, filePath: string, bean: SharetypeBean): void {
const typeId: string =
utd.getUniformDataTypeByFilenameExtension(this.extOf(filePath), utd.UniformDataType.IMAGE);
const record: systemShare.SharedRecord = {
utd: typeId,
uri: fileUri.getUriFromPath(filePath),
title: bean.title !== '' ? bean.title : undefined,
description: bean.description !== '' ? bean.description : undefined,
};
this.showSystemShare(uiCtx, record);
}
private showSystemShare(uiCtx: common.UIAbilityContext, record: systemShare.SharedRecord): void {
try {
const data: systemShare.SharedData = new systemShare.SharedData(record);
const controller: systemShare.ShareController = new systemShare.ShareController(data);
controller.show(uiCtx, {
selectionMode: systemShare.SelectionMode.SINGLE,
previewMode: systemShare.SharePreviewMode.DETAIL,
}).catch((e: BusinessError) => {
this.log.w(`systemShare show failed: code=${e.code} ${e.message}`);
});
} catch (e) {
this.log.w(`systemShare init failed: ${(e as BusinessError).message}`);
}
}
// —— 公共 ——
private reportResult(success: number, type: number): void {
const out: ShareSuccessResp = { success, type };
this.bridge?.callHandler(OutboundHandlers.ShareSuccess, JSON.stringify(out));
}
/** dataURL/纯base64 → 解码 → 写沙箱 filesDir/share/shot_<seq>.jpg|png → 返回路径;失败返回空串。 */
private saveDataUrlToFile(uiCtx: common.UIAbilityContext, dataUrl: string): string {
try {
const comma: number = dataUrl.indexOf(',');
const base64: string = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
if (base64 === '') {
return '';
}
const bytes: Uint8Array = new util.Base64Helper().decodeSync(base64);
this.seq += 1;
const ext: string = dataUrl.indexOf('image/png') >= 0 ? '.png' : '.jpg';
const filePath: string = `${uiCtx.filesDir}/share/shot_${this.seq}${ext}`;
FileSystem.writeBytes(filePath, bytes);
return filePath;
} catch (e) {
this.log.w(`saveDataUrlToFile failed: ${(e as Error).message}`);
return '';
}
}
private extOf(path: string): string {
const dot: number = path.lastIndexOf('.');
const slash: number = path.lastIndexOf('/');
return dot > slash && dot >= 0 ? path.substring(dot) : '.jpg';
}
}
- Step 2: 编译验证(含 Task 4)
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 3: 提交(Task 4 + Task 5 一起)
git add feature_capabilities/src/main/ets/wx/WeChatApi.ets feature_capabilities/src/main/ets/providers/ShareProvider.ets
git commit -m "feat(share): ShareProvider 去SDK化三段式分享 + WeChatApi 仅留登录(分享重构 §4/§5/§7)"
Task 6: 新增 ShareGuideDialog 指引弹窗
Files:
-
Create:
entry/src/main/ets/components/ShareGuideDialog.ets -
Step 1: 新建组件
/**
* 分享指引弹窗(渠道选定后弹出)。纯展示组件:动作经 onPick 回容器,
* 由容器一次性 resultEvent 回投 ShareProvider(同 SharePanel 模式,不持业务逻辑)。
* 文案按 contentKind:文本→"请打开XX粘贴分享";图片→"可在XX长按粘贴;或用系统分享"。
*/
@Component
export struct ShareGuideDialog {
/** 渠道:'wechat' | 'qq' | 'douyin'。 */
platform: string = 'wechat';
/** 内容类型:'text' | 'image'。 */
contentKind: string = 'text';
/** 动作回调:'open' | 'system' | 'cancel'。 */
onPick: (action: string) => void = () => { };
private appName(): string {
if (this.platform === 'qq') {
return 'QQ';
}
if (this.platform === 'douyin') {
return '抖音';
}
return '微信';
}
private appIcon(): Resource {
if (this.platform === 'qq') {
return $r('app.media.share_qq');
}
if (this.platform === 'douyin') {
return $r('app.media.share_douyin');
}
return $r('app.media.share_wechat');
}
private hint(): string {
const name: string = this.appName();
return this.contentKind === 'image'
? `图片已复制,可在${name}长按粘贴;或点下方用系统分享`
: `内容已复制,请打开${name}粘贴分享`;
}
build() {
Column() {
Blank()
.layoutWeight(1)
.width('100%')
.onClick(() => this.onPick('cancel'))
Column() {
Image(this.appIcon())
.width(48)
.height(48)
.objectFit(ImageFit.Contain)
.margin({ top: 16 })
Text(this.hint())
.fontSize(14)
.fontColor('#333333')
.textAlign(TextAlign.Center)
.margin({ top: 12, bottom: 12 })
.padding({ left: 20, right: 20 })
Divider().color('#EEEEEE')
Text(`打开${this.appName()}`)
.fontSize(16)
.fontColor('#1989FA')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 14, bottom: 14 })
.onClick(() => this.onPick('open'))
Divider().color('#EEEEEE')
Text('用系统分享')
.fontSize(16)
.fontColor('#333333')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 14, bottom: 14 })
.onClick(() => this.onPick('system'))
Divider().color('#EEEEEE')
Text('取消')
.fontSize(16)
.fontColor('#666666')
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 14, bottom: 14 })
.onClick(() => this.onPick('cancel'))
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius({ topLeft: 16, topRight: 16 })
.onClick(() => { /* 消费点击,避免穿透到遮罩 */ })
}
.width('100%')
.height('100%')
.backgroundColor('rgba(0,0,0,0.45)')
}
}
- Step 2: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL(组件未被引用也应通过编译)
- Step 3: 提交
git add entry/src/main/ets/components/ShareGuideDialog.ets
git commit -m "feat(share): 新增 ShareGuideDialog 指引弹窗(打开App/用系统分享/取消,分享重构 §7.2)"
Task 7: BridgeGameContainer 接线指引弹窗
Files:
-
Modify:
entry/src/main/ets/pages/BridgeGameContainer.ets(4 处:import / 状态 / 订阅+方法 / 渲染+返回键) -
Step 1: 扩充 import
把第 8-9 行的 common import 追加 ShareGuideRequest, ShareGuideResult:
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, Logger } from 'common';
在第 12 行 import { SharePanel } 后新增:
import { ShareGuideDialog } from '../components/ShareGuideDialog';
- Step 2: 新增指引窗状态(紧接现有
shareResultEvent定义之后,约第 54 行后)
/** 分享指引窗显隐(渠道选定后叠在 Stack 顶层)。 */
@State private shareGuideVisible: boolean = false;
private shareGuidePlatform: string = '';
private shareGuideKind: string = '';
/** 当前指引窗的一次性回投事件名(用户选动作后 emit 回 ShareProvider)。 */
private shareGuideResultEvent: string = '';
- Step 3: 订阅 SHOW_GUIDE(在
subscribeEvents()内 SHOW_PANEL 订阅那一行之后,约第 122 行)
this.cancels.push(EventBus.on(ShareEvents.SHOW_GUIDE, (p?: EventPayload) => this.showShareGuide(p)));
- Step 4: 新增两个方法(紧接现有
emitShareResult()方法之后,约第 147 行)
/** 收到 ShareProvider 的 SHOW_GUIDE:记下渠道/类型/一次性回投事件名并弹出指引窗。 */
private showShareGuide(p?: EventPayload): void {
if (p === undefined) {
return;
}
// 已有指引窗未关闭:先按取消回投旧请求,避免上一个 once 永不触发致 ShareProvider 悬挂。
if (this.shareGuideVisible && this.shareGuideResultEvent !== '') {
this.emitShareGuideResult('cancel');
}
const req = p as ShareGuideRequest;
this.shareGuidePlatform = req.platform;
this.shareGuideKind = req.contentKind;
this.shareGuideResultEvent = req.resultEvent;
this.shareGuideVisible = true;
}
/** 用户选定动作/取消:回投并关闭指引窗。 */
private emitShareGuideResult(action: string): void {
const ev: string = this.shareGuideResultEvent;
this.shareGuideResultEvent = '';
this.shareGuideVisible = false;
if (ev !== '') {
const result: ShareGuideResult = { action };
EventBus.emit(ev, result);
}
}
- Step 5: 渲染指引窗(在渲染 SharePanel 的
if (this.sharePanelVisible) {...}块之后,约第 392 行后)
// 分享指引窗(顶层叠加;渠道选定后弹,选动作/取消 → 一次性回投 ShareProvider)
if (this.shareGuideVisible) {
ShareGuideDialog({
platform: this.shareGuidePlatform,
contentKind: this.shareGuideKind,
onPick: (action: string) => this.emitShareGuideResult(action),
})
}
- Step 6: 返回键关闭指引窗(在
onBackPressed内if (this.sharePanelVisible) {...}块之前,约第 401 行前)
// 指引窗打开时,返回键关闭并按取消回投
if (this.shareGuideVisible) {
this.emitShareGuideResult('cancel');
return true;
}
- Step 7: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 8: 提交
git add entry/src/main/ets/pages/BridgeGameContainer.ets
git commit -m "feat(share): 容器接线 ShareGuideDialog(订阅 SHOW_GUIDE/渲染/返回键/一次性回投,分享重构 §7)"
Task 8: buildShareText 单元测试
Files:
- Create:
entry/src/ohosTest/ets/test/ShareText.test.ets - Modify:
entry/src/ohosTest/ets/test/List.test.ets(注册新测试套,若该聚合文件存在)
说明:
devecocli无 test 子命令,此测试在 DevEco Studio 内运行(Run > Test)。CLI 阶段以devecocli build编译通过为门禁。
- Step 1: 新建测试文件
import { describe, it, expect } from '@ohos/hypium';
import { buildShareText } from 'feature_capabilities';
export default function shareTextTest() {
describe('buildShareText', () => {
it('title+description 单换行', 0, () => {
expect(buildShareText('标题', '描述')).assertEqual('标题\n描述');
});
it('仅 title', 0, () => {
expect(buildShareText('标题', '')).assertEqual('标题');
});
it('仅 description', 0, () => {
expect(buildShareText('', '描述')).assertEqual('描述');
});
it('都为空返回空串', 0, () => {
expect(buildShareText('', '')).assertEqual('');
});
it('不含 url(webpageUrl 不参与拼接)', 0, () => {
expect(buildShareText('标题', '描述')).assertEqual('标题\n描述');
});
});
}
- Step 2: 注册测试套(若
entry/src/ohosTest/ets/test/List.test.ets存在)
在 List.test.ets 的聚合函数体内追加调用(与现有 xxxTest() 同级):
import shareTextTest from './ShareText.test';
// ...在 testsuite() 函数体内:
shareTextTest();
若实际测试目录结构与上述路径不同,按本仓库现有
*.test.ets的位置与注册方式对齐放置(先ls entry/src/ohosTest/ets/test/与entry/src/test/确认)。
- Step 3: 编译验证
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 4: 提交
git add entry/src/ohosTest/ets/test/ShareText.test.ets entry/src/ohosTest/ets/test/List.test.ets
git commit -m "test(share): buildShareText 单测(单换行/空字段/无url,分享重构 §9)"
Task 9: 真机验证 + 进度/风险登记
Files:
-
Modify:
docs/设计文档/Plan/01_任务分解WBS.md(T-M3-13 状态/备注) -
Modify:
docs/设计文档/Plan/03_风险登记册.md(新增图片剪贴板粘贴风险) -
Step 1: 全量构建
Run: devecocli build
Expected: BUILD SUCCESSFUL
- Step 2: 真机/模拟器行为验证(需设备)
Run: devecocli run,在 H5 触发分享,逐项核对:
-
sharefriend=="1":弹三按钮面板 → 选微信/QQ/抖音 → 弹指引窗。 -
sharefriend=="2":不弹面板,直接微信指引窗。 -
文本(type1):剪贴板为
title\n描述(无 url);「用系统分享」拉起系统面板 PLAIN_TEXT。 -
图片(type2):截图落盘;「用系统分享」拉起系统面板带图片。
-
「打开微信/QQ/抖音」:
canOpenLink通过则打开对应 App(best-effort)。 -
微信渠道:点「打开」/「用系统分享」回传
sharesuccess {success:2,type};取消回{success:3};QQ/抖音无回传。 -
人工:图片 PIXELMAP 复制后,在微信/QQ 鸿蒙版对话框长按是否能粘贴出图片 → 结果登记风险册。
-
Step 3: 更新 WBS(T-M3-13)
先读 docs/设计文档/Plan/01_任务分解WBS.md 找到 T-M3-13(分享),把其状态/备注更新为:分享去 SDK 化重构完成(剪贴板+指引+系统分享),微信乐观回传;QQ/抖音 SDK 直分享列为延期项。
- Step 4: 登记风险
先读 docs/设计文档/Plan/03_风险登记册.md,按其既有行格式追加一行风险:
-
风险:图片复制到剪贴板后,微信/QQ 鸿蒙版对话框能否粘贴出 PIXELMAP 不可保证(API 12+ 读剪贴板有授权管控)。
-
应对:图片以指引窗内
systemImage(系统分享)为可靠交付,剪贴板图片仅尽力而为;待人工真机验证结果决定是否保留剪贴板图片路径。 -
关联:分享重构 spec §10 / T-M3-13。
-
Step 5: 提交
git add docs/设计文档/Plan/01_任务分解WBS.md docs/设计文档/Plan/03_风险登记册.md
git commit -m "docs(share): 分享重构完成,更新 WBS(T-M3-13) + 登记图片剪贴板粘贴真机验证风险"
Self-Review(已对照 spec)
Spec 覆盖:
- §3 契约零改动 → 全程未改
contracts,handler/DTO 不变 ✓ - §4 流程(sharefriend 分流、指引窗三按钮) → Task 5
onShare/guide+ Task 6/7 ✓ - §5.1 文本统一单换行无 url → Task 3
buildShareText+ Task 8 单测 ✓ - §5.2 type1 文本 / type2 截图图片 / type3 下载图片 / type4 不实现 → Task 5
startShare分支 ✓ - §6 微信乐观回传 → Task 5
onGuideAction✓ - §7.1 ShareProvider 重写 + systemLink/systemVideo 退役 → Task 5(新文件无此二方法)✓
- §7.2 ShareGuideDialog → Task 6 ✓
- §7.3 WeChatApi 仅留登录 → Task 4 ✓
- §7.4 querySchemes → Task 2 ✓
- §7.5 保留 wechat_open_sdk → 未删依赖 ✓
- §7.6 支付现状已满足 → 不涉及,无任务(符合)✓
- §9 测试 → Task 8 + Task 9 真机项 ✓
- §10 风险登记 → Task 9 ✓
占位扫描: 无 TODO/TBD;所有代码步骤含完整代码。type3 标注"低优先/需确认 H5 使用"但有完整实现(非占位)。
类型一致性: buildShareText(title, description) 跨 Task 3/5/8 一致;ShareGuideRequest{platform,contentKind,resultEvent} / ShareGuideResult{action} 跨 Task 1/5/7 一致;动作字符串 'open'|'system'|'cancel'、渠道 'wechat'|'qq'|'douyin'、类型 'text'|'image' 全程一致。