封面

技术栈: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 memberresourceManager 在 @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 咨询、技术陪跑。

不是外包码农,是听得懂业务的自己人。有同类场景的朋友欢迎评论区聊聊,先聊清楚再动手不迟。

Logo

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

更多推荐