HarmonyOS 7 实战:从零写一个 ArkTS 原生 App,并接上平行视界、分栏、悬浮窗、华为分享

技术栈:HarmonyOS ArkTS / ArkUI(Stage 模型)· targetSdkVersion 26.0.0 · 零第三方依赖
完整源码:https://gitee.com/huang-jianhua-gitee/zi-wei-pal_-hm
一、先讲实现思路
写代码之前,有三个决定要先定,它们直接决定后面顺不顺。
1.1 分层:把算法从 UI 里彻底拆出来
绝大多数 ArkTS 教程是"一个页面文件写完所有事"。功能少时没问题,但一旦算法有几百行,就会出现两个恶果:算法没法单独测,改一处怕碰坏界面。
这个项目分成三层:
UI 层(pages/)
│ 只负责渲染和交互,不写任何计算
▼
引擎层(engine/)
│ 纯 TypeScript,import 里没有一处 ArkUI
│ → 因此可以在 Node 里直接跑断言,不用开模拟器
▼
系统能力层(floatview/ / share/ / 配置文件)
与业务解耦,只跟系统打交道
最关键的约束是:engine/ 目录下不允许出现 ArkUI 的 import。 只要守住这条,算法就能脱离设备验证。后面第六节的测试脚本全靠它。
1.2 算法:把口诀抄成表,而不是写成 if-else
这类传统规则算法的特点是——规则多、分支碎、每条都有出处。写成 if-else 会变成一团谁也看不懂的东西。
更好的做法是表驱动:每一条口诀对应一张数组表,代码只负责查表。好处是口诀和表能逐条对拍,这是唯一能防止"表写错一位"的办法(第 1.5 节会看到为什么这一点致命)。
1.3 系统能力:它们是"接线",不是"架构"
平行视界、分栏、悬浮窗、华为分享这四项,除了悬浮窗需要一个新页面,其余都不改变已有架构。所以放到最后做,前面先把主体跑通。
好,开始。
二、分步骤实现
步骤 1|工程骨架:先把设备形态定下来
这一步决定 App 能在哪些设备上跑、能享受哪些系统形态能力。
entry/src/main/module.json5:
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
// ★ 想让平行视界/分栏生效,deviceTypes 必须声明 tablet、2in1
"deviceTypes": ["phone", "tablet", "2in1"],
"pages": "$profile:main_pages", // 页面注册表,新增页面必须登记
"easyGo": "$profile:easy_go", // ★ 平行视界 / 应用分栏配置入口
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"exported": true,
"skills": [
{ "entities": ["entity.system.home"], "actions": ["ohos.want.action.home"] }
]
}
]
}
}
两个容易漏的点:
deviceTypes里没有tablet/2in1,后面配置写得再对也没用——系统根本不会把应用分发到那些形态上。- 新增页面(比如后面的悬浮窗页面)必须加进
pages指向的注册表,否则运行时找不到。
步骤 2|先定数据模型,再写算法
跳过这一步直接写计算,最后一定会返工。先把"一宫"和"一张盘"长什么样定下来。
entry/src/main/ets/engine/ZiWeiEngine.ets:
// 一颗星在宫里的完整信息
export interface StarInfo {
name: string;
tier: number; // 0 主星 / 2 辅星
brightness: string; // 庙旺落陷,空 = 无
sihua: string; // 生年四化,空 = 无
selfSihua: string; // 宫干自化,空 = 无
}
// 一个宫(十二宫之一)
export interface StarCell {
palaceIndex: number; // 宫序:0 = 命宫,1..11 按地支顺行偏移
palaceName: string; // 宫名(★注意:宫名与宫序不是同一个顺序,见步骤 4)
dizhi: string; // 地支
stem: string; // 天干
mainStars: StarInfo[];
auxStars: StarInfo[];
shenSha: string[];
changSheng: string;
boShi: string;
liuYue: number; // 流月所在月份,-1 = 未排
// ... 限运相关字段略
}
// 整张盘
export interface ChartResult {
solarYear: number; solarMonth: number; solarDay: number;
lunarYear: number; lunarMonth: number; lunarDay: number; isLeap: boolean;
chartMonth: number;
yearGanZhi: string; monthGanZhi: string; dayGanZhi: string; hourGanZhi: string;
mingGongIndex: number; shenGongIndex: number;
wuXingJu: string; wuXingJuNum: number;
ziWeiIndex: number; tianFuIndex: number;
palaces: StarCell[];
siHua: Record<string, string>;
}
// 输入:只暴露"必要参数",其余给默认值
export interface BuildInput {
solarYear?: number; solarMonth?: number; solarDay?: number;
lunarYear?: number; lunarMonth?: number; lunarDay?: number; isLeap?: boolean;
hour: number; minute?: number; gender: string;
longitude?: number | null;
options?: Partial<ChartOptions>;
}
把"输入"设计成"可选字段 + 选项对象",好处是调用方不必凑齐所有参数,也方便以后加流派开关而不改函数签名。
步骤 3|把口诀抄成表
这是整个引擎最有价值的部分。以两项为例:
entry/src/main/ets/engine/AnXingTables.ets:
// 地支索引约定:0-11 = 子丑寅卯辰巳午未申酉戌亥
// 天干索引约定:0-9 = 甲乙丙丁戊己庚辛壬癸
// 紫微星定位表:行 = 五行局 - 2(0 = 水二局 … 4 = 火六局),列 = 农历日 1-30
// 值 = 紫微所在地支序
export const ZI_WEI_BY_DAY: number[][] = [
// 水二局
[1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 0, 0, 1, 1, 2, 2, 3, 3, 4],
// 木三局
[4, 1, 2, 5, 2, 3, 6, 3, 4, 7, 4, 5, 8, 5, 6, 9, 6, 7, 10, 7, 8, 11, 8, 9, 0, 9, 10, 1, 10, 11],
// 金四局
[11, 4, 1, 2, 0, 5, 2, 3, 1, 6, 3, 4, 2, 7, 4, 5, 3, 8, 5, 6, 4, 9, 6, 7, 5, 10, 7, 8, 6, 11],
// 土五局
[6, 11, 4, 1, 2, 7, 0, 5, 2, 3, 8, 1, 6, 3, 4, 9, 2, 7, 4, 5, 10, 3, 8, 5, 6, 11, 4, 9, 6, 7],
// 火六局
[9, 6, 11, 4, 1, 2, 10, 7, 0, 5, 2, 3, 11, 8, 1, 6, 3, 4, 0, 9, 2, 7, 4, 5, 1, 10, 3, 8, 5, 6]
];
// 天府定位:下标 = 紫微地支序 → 天府地支序
export const TIAN_FU_BY_ZI_WEI: number[] = [4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5];
// 紫微星系:相对紫微逆布的步数
export const ZI_WEI_CLUSTER: StarStep[] = [
{ name: '天机', step: 1 },
{ name: '太阳', step: 3 },
{ name: '武曲', step: 4 },
{ name: '天同', step: 5 },
// 口诀「又隔二位廉贞地,空三复见紫微郎」:天同退 5,空两位 → 廉贞退 8
{ name: '廉贞', step: 8 }
];
为什么值得这么做:step: 8 这一行原来写的是 7。因为口诀里有"空三"这种容易数错的说法,写成公式时少算一位——每张盘都错,但代码看上去完全正常。后来是把表里的值直接跟四份独立来源逐条对拍才发现。
如果把这条规则写成 if 分支嵌在流程里,这种错误几乎不可能被发现。
步骤 4|写排盘主流程
主流程只做一件事:按固定顺序把上一步的表查完,结果塞进 StarCell[]。
entry/src/main/ets/engine/ZiWeiEngine.ets:
export function buildChart(input: BuildInput): ChartResult | null {
const opts: ChartOptions = cloneOptions(input.options ?? DEFAULT_OPTIONS);
// 1. 归一化农历(只给公历就反查,只给农历就正推)
const r = solar2lunar(input.solarYear ?? 0, input.solarMonth ?? 1, input.solarDay ?? 1);
if (!r) return null;
// 2. 时辰:真太阳时校正(可选)
const stdHour = input.hour + (input.minute ?? 0) / 60.0;
const doy = dayOfYear(input.solarYear!, input.solarMonth!, input.solarDay!);
const useLong = opts.useTrueSolarTime && input.longitude != null;
const hourOfDay = useLong
? trueSolarCorrectedHour(stdHour, input.longitude as number, doy)
: stdHour;
const hb = hourBranchFromHour(hourOfDay); // 时支
// 3. 四柱
const yearGz = opts.yearStyle === 'LiChun' ? gzYear(...) : gzYear(r.lYear);
const yearGan = TIAN_GAN.indexOf(yearGz.charAt(0));
const yearBranch = DI_ZHI.indexOf(yearGz.charAt(1));
const monthGz = monthPillarByStem(yearGan, chartMonth);
const dayGz = TIAN_GAN[di % 10] + DI_ZHI[di % 12];
const hourGz = hourPillar(di % 10, hb);
// 4. 命宫 / 身宫
const monthPos = mod12(2 + chartMonth - 1); // 寅宫起正月
const mingBranch = mod12(monthPos - hb); // 命宫:月起、时逆
const shenBranch = mod12(monthPos + hb); // 身宫:月起、时顺
// 5. 十二宫
const palaces: StarCell[] = [];
for (let i = 0; i < 12; i++) {
const dizhi = DI_ZHI[mod12(mingBranch + i)];
palaces.push({
palaceIndex: i,
// ★宫名逆布:见下方说明。这一行曾是整个项目最严重的 bug。
palaceName: PALACE_NAMES[mod12(12 - i)],
dizhi: dizhi,
mainStars: [], auxStars: [], shenSha: [],
changSheng: '', boShi: '', xiaoXianAges: [], liuNianAges: [], liuYue: -1
// ... 其余字段略
});
}
// 6. 安星:先紫微天府,再两系主星,然后辅星、煞星、四化
placeMain(palaces, mingBranch, ZI_WEI_BY_DAY[opts.wuXingJuNum - 2][r.lDay - 1], '紫微');
placeMain(palaces, mingBranch, TIAN_FU_BY_ZI_WEI[ziWeiBranch], '天府');
// ... 其余安星步骤略
return { /* ChartResult */ } as ChartResult;
}
这里必须解释那个"逆布"。 宫序 palaceIndex 用的是"地支顺行偏移",而十二宫的名字是从命宫起逆行布下去的。所以:
palaceName = PALACE_NAMES[mod12(12 - i)] ← 正确
palaceName = PALACE_NAMES[i] ← 错的(除命宫/迁移外十宫全错位)
写成 PALACE_NAMES[i] 时,命宫和迁移宫恰好落在对称位置看不出来,其余十个宫名全部错位一位,而程序跑起来完全不报错,只是"结果不太对"。定位这个问题花掉的力气,比写整个引擎还多。
教训:凡是"序号 → 名称"的映射,一定要拿一份已知正确的样例盘逐宫核对,不能只看它不报错。
步骤 5|首页:录入表单 + 本地持久化
界面部分用最朴素的 ArkUI 写法即可。要点只有一个:数据存本地,不联网。
entry/src/main/ets/pages/Index.ets:
import { preferences } from '@kit.ArkData';
const STORE_NAME = 'ziwei_recent';
// 保存最近一次输入,下次打开直接回填
export async function saveRecent(ctx: common.UIAbilityContext, item: RecentItem): Promise<void> {
const store = await preferences.getPreferences(ctx, { name: STORE_NAME });
const raw = await store.get('items', '[]') as string;
const list: RecentItem[] = JSON.parse(raw);
list.unshift(item);
// 用 JSON 字符串整体存,比逐字段写 preferences 更好维护
await store.put('items', JSON.stringify(list.slice(0, 10)));
await store.flush(); // ★ 必须 flush,否则不落盘
}
@Entry
@Component
struct Index {
@State birthDate: string = '';
@State birthHour: number = 0;
@State gender: string = '男';
build() {
Column({ space: 16 }) {
// ... 日期 / 时辰 / 性别 三个输入控件
Button($r('app.string.btn_paipan'))
.onClick(async () => {
await saveRecent(getContext(this) as common.UIAbilityContext, this.snapshot());
router.pushUrl({ url: 'pages/ChartPage', params: { input: this.snapshot() } });
})
}
}
}
两个提醒:
preferences.put()之后必须flush(),否则数据只留在内存里,关掉应用就丢。- 日期选择一定要校验非法日期(比如 2 月 31 日)。静默按 3 月 3 日排是很容易埋的坑,用户不会知道结果算错了。
步骤 6|盘面页:渲染与导出图片
渲染就是把 StarCell[] 铺成 4×4 网格。真正容易翻车的是导出图片。
entry/src/main/ets/pages/ChartPage.ets:
// 盘面截图 → JPEG → 写入沙箱文件,返回路径。导出与分享共用这一段。
private async captureChartJpeg(fileBaseName: string): Promise<string | null> {
if (!this.chart) return null;
let pm: image.PixelMap | null = null;
const packer: image.ImagePacker = image.createImagePacker();
const ctx = getContext(this) as common.UIAbilityContext;
try {
// 1. 按组件 id 截图
pm = await componentSnapshot.get('chartRoot');
// 2. 打包成 JPEG(quality 96 兼顾体积与清晰度)
const opt: image.PackingOption = { format: 'image/jpeg', quality: 96 };
const buf: ArrayBuffer = await packer.packing(pm, opt);
// 3. 写进沙箱
const path = ctx.filesDir + '/' + fileBaseName + '.jpg';
const f = fileIo.openSync(path,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
fileIo.writeSync(f.fd, buf);
fileIo.closeSync(f);
return path;
} catch (e) {
return null;
} finally {
// ★ 一定要释放,否则反复导出会涨内存
if (pm !== null) pm.release();
packer.release();
}
}
被截图的那个容器必须自己带底色。 如果 chartRoot 自己是透明的(底色画在了外层容器上),JPEG 会把透明区编码成黑色,导出的图跟屏幕上看到的完全不是一个东西。
存到系统相册:
const cfg: photoAccessHelper.PhotoCreationConfig = {
title: name, fileNameExtension: 'jpg', photoType: photoAccessHelper.PhotoType.IMAGE
};
const ph = photoAccessHelper.getPhotoAccessHelper(ctx);
// 弹系统存图框,用户点允许后返回一个「已授权的空文件 URI」
const uris: string[] = await ph.showAssetsCreationDialog([fileUri.getUriFromPath(path)], [cfg]);
if (uris.length === 0) return; // 用户取消
// ★★ 关键:系统只给 URI,不会帮你复制内容,必须自己把沙箱文件写进去
const src = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const des = fileIo.openSync(uris[0], fileIo.OpenMode.WRITE_ONLY);
fileIo.copyFileSync(src.fd, des.fd);
fileIo.closeSync(src);
fileIo.closeSync(des);
漏掉最后这一步的现象非常迷惑人:弹框正常、点了允许、应用报"已保存",但打开图库什么都没有。而 Toast 是应用自己弹的,所以它"提示成功"根本不能作为判断依据——必须真的去相册里看一眼。
走系统存图框还有个附带好处:不需要申请相册写权限,对"数据不出本机"的定位很重要。
步骤 7|国际化:四语言 + 繁简显示层转换
资源文件放四套,键序保持一致:
entry/src/main/resources/
base/element/string.json (默认,简体)
zh_CN/element/string.json
en_US/element/string.json
zh_Hant/element/string.json (繁体)
界面用 $r('app.string.xxx') 即可自动跟随语言。
但盘面上的星名、宫名不在资源里——它们是数据。这时候不要把它们做成三套资源,而是在显示层转换:
entry/src/main/ets/engine/TextConv.ets:
import { i18n } from '@kit.LocalizationKit';
import { HANT_MAP } from './HantMap';
// 逐字映射。查不到键(繁体/拉丁/数字/标点)时原样返回,所以是恒等安全的
export function toHant(s: string): string {
let out = '';
for (let i = 0; i < s.length; i++) {
const c = s.charAt(i);
out += HANT_MAP[c] ?? c;
}
return out;
}
export function isHantNow(): boolean {
let p = '';
try {
p = i18n.System.getAppPreferredLanguage();
} catch (e) {
return false;
}
return p.indexOf('Hant') >= 0 || p.indexOf('TW') >= 0
|| p.indexOf('HK') >= 0 || p.indexOf('MO') >= 0;
}
// 非繁体模式恒等返回,调用方不需要自己判断分支
export function hant(s: string): string {
return isHantNow() ? toHant(s) : s;
}
为什么不能直接改数据模型里的字:引擎内部靠字面量反查,比如 MAIN_STAR_NAMES.indexOf(s.name)。一旦把数据里的星名换成繁体,这些 indexOf 全部返回 -1,庙旺和星属信息会静默失效——不报错,只是结果空掉。
所以规矩是:数据层保持简体,只在渲染的地方包一层 hant()。
还有个反直觉的点:HANT_MAP 里只收"简体字"。所以从资源文件里取出的繁体文案再进 hant() 是安全的(繁体字查不到键,原样返回),不会出现"繁体被转成另一个繁体"的问题。
步骤 8|平行视界与应用分栏(纯配置,零页面代码)
这两项完全不需要改页面代码,配一个文件就行。
entry/src/main/resources/base/profile/easy_go.json:
{
"common": {
"displayModeOptions": {
// ★ 本项目用 @ohos.router 做路由,所以这里写 routerSplit
// 如果用的是 Navigation,配置键是 split / navigationSplit,别抄错
"wideWindowMode": "routerSplit",
"squareWindowMode": "routerSplit",
"routerSplitOptions": {
"homePage": "pages/Index", // 左侧:列表页
"relatedPage": "pages/ChartPage", // 右侧:详情页
"fullScreenPages": [ // 这些页面不吃分栏,独占全屏
"pages/SettingsPage",
"pages/PrivacyPage"
],
"supportLandscapeFullscreen": true,
"wideSplit": { "isDraggable": true }, // ★ 允许拖拽调比例 = 1:2 / 2:1
"squareSplit": { "isDraggable": true },
"mode": 0,
"splitDividerColor": { "light": "#339B2E23", "dark": "#339B2E23" }
}
}
}
}
配套在 module.json5 里挂一行(见步骤 1):
"easyGo": "$profile:easy_go"
搞完这两行,折叠屏 / 平板 / 2in1 上自动变成"左列表右详情",还能拖中间的分隔条。一个页面文件都没动。
踩过的坑:路由方式决定配置键名。@ohos.router 用 routerSplit,Navigation 用 split——抄错的话编译不报错,只是完全不生效。
步骤 9|悬浮窗:闪控球 ↔ 闪控窗
这是唯一需要新增页面的一项。整体链路是:创建控制器 → 指定悬浮窗页面 → 把球和窗绑定 → 启动。
entry/src/main/ets/floatview/FloatToday.ets:
import { floatView, floatingBall } from '@kit.ArkUI';
const FLOAT_PAGE = 'pages/FloatPage'; // 必须在 main_pages.json 里登记过
static async start(ctx: common.UIAbilityContext, text: BallText): Promise<FloatStartResult> {
if (!FloatToday.supported()) return FloatStartResult.UNSUPPORTED;
if (FloatToday.started) return FloatStartResult.ALREADY_RUNNING;
try {
// 1. 创建闪控窗控制器(常规态 = 圆角矩形)
const fvc = await floatView.create({
context: ctx,
templateType: floatView.FloatViewTemplateType.ROUNDED_RECTANGLE,
isConfirmOnClose: false
});
// 2. 创建闪控球控制器
const fbc = await floatingBall.create({ context: ctx });
// 3. 指定展开后显示的页面
await fvc.setUIContext(FLOAT_PAGE);
// 4. 绑定:★两者都必须还没 start,顺序不能换
await floatView.bind(fvc, fbc, FloatToday.ballParams(text));
// 5. 设置尺寸(不写死,按系统给的上下限推)
await fvc.setWindowSize(FloatToday.pickSize(FloatToday.shape, ctx));
// 6. 启动
await fvc.start();
FloatToday.started = true;
return FloatStartResult.OK;
} catch (e) {
const err = e as BusinessError;
if (err.code === 201) return FloatStartResult.PERMISSION_DENIED;
if (err.code === 801) return FloatStartResult.UNSUPPORTED;
return FloatStartResult.FAILED;
}
}
"窗随屏变"不要写死尺寸:系统给了查询接口,按它的上下限推才是对的,换设备不会错位。
// 按模板档位取尺寸:手机取窄档、平板/2in1 取宽档;边界一律以系统 limits 为准
private static pickSize(shape: FloatShape, ctx: common.UIAbilityContext): window.Size {
const limits = floatView.getFloatViewLimits(templateOf(shape));
let isLarge = false;
try {
const dt = ctx.resourceManager.getDeviceCapabilitySync().deviceType;
isLarge = dt === resourceManager.DeviceType.DEVICE_TYPE_TABLET
|| dt === resourceManager.DeviceType.DEVICE_TYPE_2IN1;
} catch (e) { isLarge = false; }
const ratio = isLarge ? 1.05 : 1.25;
let w = limits.maxSize.width;
let h = Math.round(w * ratio);
if (h > limits.maxSize.height) { h = limits.maxSize.height; w = Math.round(h / ratio); }
// ... 再夹进 minSize 与 ratioLimits 区间
return { width: w, height: h };
}
换形态就用 switchTemplate(),回主界面用 restoreMainWindow()。
这一项有个必须提前说的门槛:floatView / floatingBall 需要的 ohos.permission.FLOAT_VIEW 与 ohos.permission.USE_FLOAT_BALL 都是 system_basic 级受限权限,要单独申请授权并写进 Profile 才能声明。
所以本项目的做法是:
// ★ 授权未获批前保持 false:不声明权限,避免 Profile 申请直接失败
const GATE_ACL_APPROVED: boolean = false;
static async start(ctx, text) {
if (!GATE_ACL_APPROVED) return FloatStartResult.NEED_ACL; // 只提示,不申请
// ...
}
不要把受限权限当成普通权限直接写进 requestPermissions。 没获批就声明,会导致后续申请流程直接失败,把整个发布链路卡死。代码写完整、开关默认关,是更稳的做法。
步骤 10|华为分享
分享面板是系统能力,应用侧只负责把数据装进去。
entry/src/main/ets/share/ChartShare.ets:
// ★ 注意 import 来源:是 @kit.ShareKit,不是 @kit.ArkUI
import { systemShare, harmonyShare } from '@kit.ShareKit';
static async shareImage(ctx: common.UIAbilityContext, title: string): Promise<ShareResult> {
const uri = await ChartShare.currentUri();
if (!uri) return ShareResult.FAILED;
try {
const record: systemShare.SharedRecord = {
utd: 'general.image', // UDMF 统一数据类型
uri: uri,
title: title,
label: 'IMAGE'
};
const ctrl = new systemShare.ShareController(new systemShare.SharedData(record));
// DETAIL 预览模式:图片/视频推荐,面板里会自带各个分享通道
await ctrl.show(ctx, {
selectionMode: systemShare.SelectionMode.SINGLE,
previewMode: systemShare.SharePreviewMode.DETAIL
});
return ShareResult.OK;
} catch (e) {
const err = e as BusinessError;
if (err.code === 801) return ShareResult.UNSUPPORTED; // 设备不支持
return ShareResult.FAILED;
}
}
这里有个反直觉的省事点:「加密分享(可禁止截屏)」是系统面板自带的一个通道,应用侧完全不用写代码。 如果内容是半私密的,直接借这个通道就行,比自己实现加密省掉一整个模块。
碰一碰 / 隔空分享也只要注册一下,不需要额外权限:
static async registerKnock(ctx: common.UIAbilityContext): Promise<boolean> {
const win = await window.getLastWindow(ctx);
const cap: harmonyShare.SendCapabilityRegistry = { windowId: win.getWindowProperties().id };
const cb = (target: harmonyShare.SharableTarget): void => {
ChartShare.serveKnock(target).catch(() => {}); // 取图是异步的,用 then/catch 处理,别阻塞系统回调
};
harmonyShare.on('knockShare', cap, cb);
harmonyShare.on('gesturesShare', cap, cb);
return true;
}
被碰时如果当前没有内容,要明确拒绝,不要沉默:
private static async serveKnock(target: harmonyShare.SharableTarget): Promise<void> {
const uri = await ChartShare.currentUri();
if (!uri) {
// ★ 不拒绝的话,对方会一直在那里转圈
await target.reject(harmonyShare.SharableErrorCode.NO_CONTENT_ERROR);
return;
}
await target.share(/* 分享数据 */);
}
页面隐藏时要解绑,否则跨页面重复注册:
aboutToDisappear(): void { ChartShare.unregisterKnock(); }
踩过的坑:resourceManager 要从 @kit.LocalizationKit 拿,不在 @kit.AbilityKit;systemShare / harmonyShare 要从 @kit.ShareKit 拿,不在 @kit.ArkUI。第一次写这一块连报 7 个编译错误,全是 import 归属搞错——网上不少示例是旧版本的写法,别照抄。
步骤 11|让引擎可被测试,然后打包
回到第 1.1 节那个约束:引擎不依赖 ArkUI,所以能在 Node 里直接跑。
这里必须讲一个差点造成灾难的发现。 项目一开始有一份"引擎行为测试",98 条断言全绿。但它是一份算法复刻版——把算法用 JS 又写了一遍来测,并不读源码。
结果就是:源码里的表写错了,这份测试照样全绿。它测的是"复刻版对不对",不是"源码对不对"。
后来改成从源码里用正则把表抠出来,跟口诀逐条对拍:
# tests/verify_tables.py(思路)
# 1. 从 .ets 源码里正则抠出星曜表的真实数值
# 2. 跟权威口诀逐条比对
# 3. 另外扫描「表长度 vs 下标最大可达值」,防越界
改成这种方式后,报告是 142 条断言。关键是它能发现源码里的错——前面提到的"廉贞步数 7/8""十二宫宫名顺布/逆布""禄存表错一位",都是这套脚本(或它的前身)抓出来的。
结论:表驱动的代码,测试必须对着源表测。 复刻版测试看起来很专业,但它测的是自己的影子。
最后打包:
export JAVA_HOME="<DevEco>/jbr"
export DEVECO_SDK_HOME="<DevEco>/sdk"
export PATH="$JAVA_HOME/bin:$PATH" # ★ 少了这句,打包阶段会 spawn java ENOENT
"<DevEco>/tools/node/node.exe" "<DevEco>/tools/hvigor/bin/hvigorw.js" \
--mode module -p product=default assembleHap --no-daemon
产物在 entry/build/default/outputs/default/ 下。jbr/bin 必须进 PATH——ArkTS 编译本身不需要 java,但最后打包那一步需要,只设 JAVA_HOME 是不够的。
三、几个真踩过的坑(速查)
| 坑 | 现象 | 原因 |
|---|---|---|
| 十二宫宫名顺布 | 除命宫/迁移外十宫名全错位,程序不报错 | 宫名要从命宫起逆行,取 PALACE_NAMES[mod12(12 - i)] |
| 廉贞步数写成 7 | 每张盘都错,代码看上去正常 | 口诀"空三"易数错,实为退 8 |
| 导出后相册是空的 | 弹框正常、提示成功、图库里什么都没有 | showAssetsCreationDialog 只返回空 URI,必须自己 copyFileSync |
| 导出图变黑 | 导出图与屏幕颜色完全不同 | 被截图容器没给底色,JPEG 把透明区编码成黑 |
| 繁体下属性失效 | 切繁体后庙旺/星属信息空了 | 数据模型被改成繁体,indexOf 全返回 -1。只转显示层 |
| 编译报 7 个错 | has no exported member | resourceManager 在 @kit.LocalizationKit;分享在 @kit.ShareKit |
| 平行视界不生效 | 配置写了没反应 | deviceTypes 没声明 tablet/2in1,或路由键名写错 |
打包 spawn java ENOENT | 编译过了,打包失败 | 只设了 JAVA_HOME,jbr/bin 没进 PATH |
四、源码
完整工程(含全部引擎、页面、测试脚本):
https://gitee.com/huang-jianhua-gitee/zi-wei-pal_-hm
仓库里没有签名材料,克隆后按步骤 1 在 DevEco 里勾一次"自动生成签名"即可运行。
关于我
13 年 IT 全栈,.NET、Python、Office 自动化(VSTO/VBA/Python)都做,也做 AI 应用实操(智能体开发),主业是帮企业和团队把重复劳动自动化——报表一键生成、数据对接、文档批量处理、工具定制。
本科物理化学、硕士计算机化学,在一线实验室待过 13 年(9 年制药 + 4 年第三方检测),现在主要服务药企、检测机构和实验室:SOP、合规、样本流、仪器数据接口这些词不用你解释。
这几年做得比较多的是这几类:Office 自动化(VSTO/VBA/Python)工具定制、数据对接与 LIMS 咨询、技术陪跑。
不是外包码农,是听得懂业务的自己人。有同类场景的朋友欢迎评论区聊聊,先聊清楚再动手不迟。
更多推荐




所有评论(0)