Files
MailUI4Agents/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
JianFeeeee 8267102cd6 跨端: 修「我的」页显得非常挤(三处真因)+ 顺手清掉 3 条已修的自有 ArkTS 告警
用户:「「我的」页面显得非常挤」

先量再改。取了宽屏(3184px)与窄屏(1008px)两态,用 `dumpLayout` 拿真实 bounds,
反推 density=2.875。三个真因,**宽度是主因**。

## ① 内容列没有宽度上限(主因)

WebUI `AccountPage.tsx:104`:

    <div className="w-full max-w-3xl mx-auto px-4 md:px-6 lg:px-10 py-6 space-y-6">

`max-w-3xl` = **48rem = 768px** + `mx-auto` 居中。
鸿蒙这边原来只有 `.width('100%')` —— **没有任何上限**。

后果(实测):宽屏下卡片铺满 **2923px(1376vp)**,而里面全是
"标签 + 短值"的两列行,文字只占左边一小块 ⇒ 右边一大片留白,
整页读起来像一条被拉长的表格。这正是"挤"的观感来源 ——
不是行距小(实测行距 30vp / 字 14vp,其实偏松),是**横向没有收拢**。

修后实测:内容列 **2208px = 768.0vp**(与目标逐位吻合),
中心 1692.0 vs 父容器 1692.5 ⇒ 居中生效。

★ 形状:`Scroll > Row(居中容器)> Column(带上限)`。
  **不能把居中挂在 `Scroll` 上** —— 官方 `ScrollAttribute` 没有
  `.justifyContent()`。我第一版就是直接给 Scroll 挂,那是个编出来的属性。
  ArkUI 没有 `mx-auto` 的直接对应物,"居中"必须靠一个容器节点。

★ 窄屏(1008px = 350vp < 768)下约束**自动不生效**,逐像素确认窄屏形态未变。

## ② 段间距只有 WebUI 的 1/3

WebUI `space-y-6` = **24px**;我们每张卡各写 `margin({ top: 8 })`(7 处)
与 `margin({ top: 10 })`(1 处)—— 既不统一,也贴得太近,
卡片挨在一起时"段"的边界看不出来,整页就是一大块密集表单。

⇒ 收成一个 `SECTION_GAP = 24` 常量,8 处全部改用它。
实测卡片间实测 **69px = 24.0vp** ✓

## ③ `SectionTitle()` 定义了却只调用一次

WebUI `AccountPage` 有 **5 个 `<h3>`** 段标题(基本资料 / 权限范围 /
修改密码 / 登录状态 / 管理),鸿蒙这边只有 **1 个**('权限范围')——
基本资料那六行是裸放在卡片里的,与下面的权限范围只靠一条 `Divider` 硬分。
⇒ 补「基本资料」标题。没有标题就没有"这是另一段"的语义,只能靠线。

## ④ 顺手清掉 3 条自有 ArkTS 告警(37 → 34)

上一条提交我说"`fill` 那 2 处留给下一轮"—— 不该推,这轮做完:

- **2× `fill` API 是 SDK 26.0.0**(`Circle().fill()`,在 WideSidebar 连接点
  与 InboxPage/MainPage 未读点)。查 SDK 头文件确认不是误报:
  `circle.d.ts` 的 `CircleAttribute.fill()` 标 **@since 26.0.0**,
  而基类 `CommonShapeMethod.fill()` 才是 @since 11 —— 子类重载**遮蔽**了它。
  设备实测点**确实渲染**(那是碰巧兼容),但契约上它比编译目标(23)新。
  ⇒ 换成与 `CalendarPage` 小圆点同一形状(普通容器 + width/height + borderRadius),
  只用 API 11 起的通用属性。
- **1× `'packing' has been deprecated`** → `packToData`(SDK 明确给了
  `@useinstead image.ImagePacker#packToData`;两者签名逐字相同:
  `(PixelMap, PackingOption) => Promise<ArrayBuffer>`,只改名字)。
- **1× `This API is unavailable to 2in1`** → 加 `deviceInfo.deviceType !== '2in1'`
  守卫。SDK 原文:「From API version 12, this API does not take effect on
  2-in-1 devices.」告警仍在(静态检查不看运行时分支),但行为已正确分流 ——
  留着这条注释说明为什么不断言消失。

★ 这条告警的噪声价值:同一批里就藏着 `fill` 那个真隐患。
  逐条筛之前,它只是"每次重编都出现的一行字"。

## 验证

✓ 宽屏:内容列 768.0vp、居中、段间距 24.0vp(`dumpLayout` 实测 bounds)
✓ 窄屏:约束不生效、形态与改前一致(像素对照)
✓ 自有 ArkTS 告警 37 → 34(`fill`×2 与 `packing` 消失)
✓ 编译通过、设备安装后进程存活
2026-09-22 08:58:56 +08:00

429 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 背景选择器(不设 / 预设 / 自定义图片)+ **P4c 壁纸上传**。
*
* 对应 WebUI 的 `client/electron/src/components/BackgroundPicker.tsx`(三选一 + 预设网格 +
* 图片上传 + 浓度滑杆),照它踩过的三条做(`docs/HARMONY-ALIGN-PLAN.md` §7.17 的 P4c 段):
*
* ① **先压缩再上传**(手机直出照片 4–8MB,服务端上限 4MB ⇒ 直传必然 413);
* ② **失败必须给原因**(别静默失败);
* ③ **上传成功后仍以服务端为权威**(`saved` 那套规则对图片同样适用)。
*
* ── 这个组件的边界(与设置页的分工)──
*
* 本组件**只管画与选**:四个值用 `@Link` 双向绑到设置页的 `@State`,
* 设置页那边 `@Watch` 到变化就推服务端(`setBackground`,与既有的 `setTheme` 同一个形状)。
* 组件里**不写**服务端调用 —— 两个理由:
* ① 本仓库既有的父子通信只有 `@Prop`/`@State`,没有"传回调函数"的先例
* (`CalendarPage` 被 `MainPage` 用时只传 `bgActive`/`visible`),
* 凭空引入一种新接法会让下一个人看不懂数据从哪来;
* ② 推服务端要 `ApiClient` + `AppearanceStore` + 本地缓存,那是**页面**的职责。
*
* **唯一一处例外**是上传:它必须自己走 `picker`/`image`/`upload` 那一条链
* (设置页拿不到 uri),所以上传完成后它调用 `onUploaded` 请页面重新同步。
*
* 压缩的**数值**不在这个文件里 —— 在 `model/ImagePrep.ts`(纯逻辑、判据直接跑):
* 这里只负责"拿到路径 → 解码 → 按计划压 → 上传 → 报结果"。
*
* ⚠️ **视觉未验**:本机无设备/无模拟器,配色与观感一律未验(只保证机制与数值)。
*/
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ApiClient } from '../api/ApiClient';
import { AppearanceApi } from '../api/AppearanceApi';
import { Theme } from '../common/Theme';
import { PRESET_IDS, presetLabel, normalizePreset } from '../model/Wallpaper';
import {
CompressPlan,
CompressPass,
MAX_EDGE,
PickJudgement,
TOO_LARGE_REASON,
UPLOAD_OK_HINT,
estimateSourceBytes,
judgePick,
planCompress,
scaleToMaxEdge,
shouldRetryWithActual,
uploadFailureHint
} from '../model/ImagePrep';
/** 三选一的选项(与 WebUI 的 `options` 逐项对应) */
class KindOption {
value: string = 'none';
label: string = '';
}
function kindOption(value: string, label: string): KindOption {
const o: KindOption = new KindOption();
o.value = value;
o.label = label;
return o;
}
const KIND_OPTIONS: KindOption[] = [
kindOption('none', '不设'),
kindOption('preset', '预设'),
kindOption('image', '自定义图片')
];
@Component
export struct BackgroundPicker {
/*
* 背景四值:`@Link` 双向绑(V1 家规:**@Link 不许给初值**,父组件用 `$bgKind` 传)。
* 组件改它们 = 立刻改到页面的 @State(界面即时反馈),随后由 `onUserChanged` 通知页面推服务端。
*/
/** 背景档:none | preset | image */
@Link bgKind: string;
/** 预设 id */
@Link bgPresetId: string;
/** 浓度(口径与 clamp 在 model/Appearance.ts) */
@Link bgDim: number;
/** 模糊 */
@Link bgBlur: number;
/** 服务端状态文案(只读透传:组件不解释它) */
@Prop statusText: string = '';
/**
* **用户**改了背景四值 ⇒ 通知页面推服务端。
*
* ★ 只在**用户动作**里调(onClick / onChange),**不在** `@Link` 值被页面
* 复制进来时调 —— 否则"服务端同步进来"会被当成"用户改的"再推一次。
* 组件自己不知道值是谁改的,所以这个区分只能靠**调用点**(见各处 onClick)。
*/
onUserChanged: (kind: string, presetId: string, dim: number, blur: number) => void = () => {};
/** 上传成功后请页面**重新以服务端为准同步**(P4c 第③条:组件不自己宣布成功) */
onUploaded: (message: string) => void = () => {};
/** 上传失败的原因(页面负责显示;空串=没事) */
onUploadFailed: (reason: string) => void = () => {};
private client: ApiClient | null = null;
@State uploading: boolean = false;
/** 进度文案(**必须显示**:这一步可能几秒,没有反馈就会被当成卡死) */
@State progress: string = '';
/** 上传失败的原因(**不吞**,就地也显示一份) */
@State uploadError: string = '';
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.client = ApiClient.getInstance(ctx);
}
}
/**
* P4c 主流程:选图 → 判可不可以 → 逐档解码压缩 → 上传 → 请页面重新同步。
*
* ★ 每一步失败都**带原因**(第②条),而且**两条路都给**:就地显示(`uploadError`)
* 与回调页面(`onUploadFailed`)。只给一处的话,换一个父组件就会静默。
*/
async pickAndUpload(): Promise<void> {
const client: ApiClient | null = this.client;
if (client === null) {
this.fail('还没拿到网络客户端(页面没初始化完?)');
return;
}
this.uploadError = '';
this.progress = '';
// ① 选图:用**照片选择器(picker)**,**不需要媒体权限**(自己去读媒体库才需要)。
// 走 `photoAccessHelper` 而不是 `@kit.CoreFileKit` 的 `picker`:
// SDK 里 `@ohos.file.picker` 的这几个类都标了 `@deprecated`
// (`@useinstead @ohos.file.photoAccessHelper:...`),两边 API 形状相同。
// 用废弃入口的后果不是"编不过",而是**某天构建开始报警告、下一个人不知道该换哪个**。
let uri: string = '';
try {
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const photoPicker = new photoAccessHelper.PhotoViewPicker();
const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(options);
if (result.photoUris.length === 0) {
return; // 用户取消:不是错误,什么都不说(说"失败"会让人以为自己点错了)
}
uri = result.photoUris[0];
} catch (e) {
this.fail('打开相册失败:' + businessMessage(e));
return;
}
let packed: ArrayBuffer | null = null;
let usedBytes: number = 0;
/**
* 最后一档压完**是否仍超限**。
*
* ★ 这个变量存在的理由是"把判定收成一处":见下面 for 循环里那段说明 ——
* 循环里判一次(决定 break 还是试下一档),循环外只读这个**结论**,不重算。
*/
let overLimit: boolean = false;
this.uploading = true;
try {
// ② 读原始尺寸,先过入口检查。
//
// ★ `head` 用 try/finally 收口**只释放一次**:这条路径上有三处出口
// (入口检查不过 / 压完仍超限 / 正常走完),每处各写一次 release
// 就会漏一处或重一处 —— 重一处是"对一个已释放对象再 release",
// 在真机上是难查的原生层异常。**释放写在 finally 里就不会有第二个答案。**
const head: image.ImageSource = image.createImageSource(uri);
try {
const info: image.ImageInfo = await head.getImageInfo();
const sourceBytes: number = estimateSourceBytes(info.size.width, info.size.height);
const verdict: PickJudgement = judgePick(sourceBytes, 'image/jpeg');
if (!verdict.ok) {
this.fail(verdict.reason);
return;
}
// ③ 逐档压。每档都**从原 uri 重新解码到目标尺寸**(`DecodingOptions.desiredSize`):
// 峰值内存只有"目标尺寸"那一份,而不是"原图 + 缩放副本"两份
// (4K 照片解码后约 48MB,两份会把低端机推爆)。
// 代价是解码两次 —— 只在第一档超限时才发生,正常照片一次都不多。
const plan: CompressPlan = planCompress(info.size.width, info.size.height);
for (let i = 0; i < plan.passes.length; i++) {
const pass: CompressPass = plan.passes[i];
this.progress = '压缩中(第 ' + pass.attempt + ' 档,最长边 ' + pass.maxEdge + ')…';
const size = scaleToMaxEdge(info.size.width, info.size.height, pass.maxEdge);
const passSource: image.ImageSource = image.createImageSource(uri);
// 同样收口:解码或压缩中途抛异常时,这个 ImageSource 也必须释放
try {
const decodeOptions: image.DecodingOptions = {
desiredSize: { width: size.width, height: size.height }
};
const scaled: image.PixelMap = await passSource.createPixelMap(decodeOptions);
try {
packed = await packJpeg(scaled, pass.quality);
usedBytes = packed.byteLength;
} finally {
await scaled.release();
}
} finally {
await passSource.release();
}
/*
* ④ 用**真实**字节数判要不要退下一档(估算只用来决定"值不值得先试第一档")。
*
* ★ 这里就是**唯一**的"要不要再压一档"判定 —— 循环结束后**不再重判一次**。
* 原来循环外面还有一句
* 循环后一句「若(没压出来 或 仍超限)则 fail(TOO_LARGE_REASON)」,
* 而那两句是**互相抵消**的,把真正的判据架空了:
* · 超限 ⇒ 循环不 break(去试第二档);两档都超限 ⇒ 循环自然走完、`usedBytes` 仍超限 ⇒ 那句红;
* · 第二档压完不超限 ⇒ 循环 break ⇒ 那句也不红。
* 结果:把循环里的 `break` 改成 `if (true)`(**永远只压一档,第二档彻底死掉**),
* 整套判据照样全绿 —— 因为超限这件事被循环外那句接住了。
* 而循环外那句自己也有个洞:它不区分"第一档超了就认定失败"和"两档都超了"。
*
* ⇒ 收成一处:**循环里判**(决定 break / 试下一档),循环外只看 `overLimit` 这个结论。
*/
overLimit = shouldRetryWithActual(usedBytes);
if (!overLimit) {
break;
}
}
if (packed === null || overLimit) {
this.fail(TOO_LARGE_REASON);
return;
}
} finally {
await head.release();
}
// ⑤ 上传(内存直传,不落临时文件)
this.progress = '上传中(' + Math.round(usedBytes / 1024) + ' KB)…';
await new AppearanceApi(client).uploadImageBytes(packed, 'wallpaper.jpg');
// ⑥ 服务端是权威:**请页面**重新同步,而不是组件自己宣布成功并改档位
this.progress = '已上传,正在以服务端为准重新同步…';
this.onUploaded(UPLOAD_OK_HINT);
} catch (e) {
// 服务端的 415「壁纸必须是图片…」/413「超过上限…」文案在这里原样透出
this.fail(uploadFailureHint(businessMessage(e)));
} finally {
this.uploading = false;
this.progress = '';
}
}
/** 一处收口:把"用户改了"这件事报给页面(调用点只有一个形状,免得漏掉某个入口) */
private emitUserChange(): void {
this.onUserChanged(this.bgKind, this.bgPresetId, this.bgDim, this.bgBlur);
}
/** 一处收口:就地显示 + 通知页面(两条路都给,换父组件也不会静默) */
private fail(reason: string): void {
this.uploadError = reason;
this.onUploadFailed(reason);
}
build() {
Column() {
Row() {
Text('背景').fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1)
Text(this.statusText).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
}
.width('100%')
Row() {
ForEach(KIND_OPTIONS, (opt: KindOption) => {
Text(opt.label)
.fontSize(Theme.fontSmall)
.fontColor(this.bgKind === opt.value ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgKind === opt.value ? Theme.accent : Theme.surfaceMuted)
.borderRadius(Theme.radiusControl)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => { this.bgKind = opt.value; this.emitUserChange(); })
}, (opt: KindOption) => opt.value)
}
.width('100%').margin({ top: 10 })
if (this.bgKind === 'preset') {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(PRESET_IDS, (pid: string) => {
Text(presetLabel(pid))
.fontSize(Theme.fontTiny)
.fontColor(this.bgPresetId === pid ? Theme.accentFg : Theme.textMuted)
.backgroundColor(this.bgPresetId === pid ? Theme.accent : Theme.surfaceMuted)
.borderRadius(4)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.margin({ right: 6, top: 6 })
.onClick(() => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); })
}, (pid: string) => pid)
}
.width('100%').margin({ top: 8 })
}
if (this.bgKind === 'image') {
Column() {
Button(this.uploading ? '处理中…' : '选择图片并上传')
.height(38).fontSize(Theme.fontSmall)
.backgroundColor(Theme.accent).fontColor(Theme.accentFg)
.enabled(!this.uploading)
.onClick(() => { this.pickAndUpload(); })
if (this.progress.length > 0) {
Text(this.progress).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
.width('100%').margin({ top: 6 })
}
if (this.uploadError.length > 0) {
Text(this.uploadError)
.fontSize(Theme.fontTiny).fontColor(Theme.dangerFor())
.width('100%').padding(8).margin({ top: 6 })
.backgroundColor(Theme.dangerBgFor()).borderRadius(Theme.radiusControl)
}
Text('上传前会先压到最长边 ' + MAX_EDGE + ' 像素(手机直出照片 4–8MB,服务端上限 4MB)。')
.fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
.width('100%').margin({ top: 6 })
}
.width('100%').alignItems(HorizontalAlign.Start).margin({ top: 8 })
}
if (this.bgKind !== 'none') {
this.DimSlider()
this.BlurSlider()
}
}
.width('100%').alignItems(HorizontalAlign.Start)
}
/**
* 浓度滑杆。
*
* ★ 成员名是 `bgDim`/`bgBlur` 这种领域名,**不叫** `opacity`:
* `@State opacity` 会与通用属性重名(ArkTS 那条"成员名不得与通用属性冲突")。
*/
@Builder
DimSlider() {
Column() {
Row() {
/*
* ★★ 2026-09-18 修两处(都是"看着就不对",只在设备上跑才看得见):
*
* ① 标签:原来是「浓度」,WebUI `BackgroundPicker.tsx:172` 写的是**「压暗」**
* (`label="压暗"`,`hint="背景越花,正文越需要一层遮罩才读得动"`)。
* "浓度"是个没主语的词,看不出在调什么;"压暗"直接说了这一层在干什么。
*
* ② 数值:原来 `Text('' + this.bgDim)` —— 屏上印的是 **`56.000000`**。
* 原因是这个值来自服务端(或本地缓存)的数值字段,是 number,
* 字符串拼接把浮点原样吐出来了。WebUI 的 `Slider` 带 `suffix="%"`,
* 显示成 `56%`。⇒ 取整 + 单位。
*
* ★ 单位要跟 WebUI 一样:压暗是**百分比**(0-80 就是 0%-80%),模糊是 **px**。
*/
Text('压暗').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Blank()
Text(Math.round(this.bgDim) + '%').fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
}
.width('100%')
Slider({ value: this.bgDim, min: 0, max: 80, step: 1 })
.width('100%')
.onChange((v: number) => { this.bgDim = Math.round(v); this.emitUserChange(); })
}
.width('100%').margin({ top: 10 })
}
@Builder
BlurSlider() {
Column() {
Row() {
Text('模糊').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
Blank()
/* 同 ①:原先印 `3.000000`;WebUI 是 `suffix="px"` ⇒ `3px` */
Text(Math.round(this.bgBlur) + 'px').fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor())
}
.width('100%')
Slider({ value: this.bgBlur, min: 0, max: 40, step: 1 })
.width('100%')
.onChange((v: number) => { this.bgBlur = Math.round(v); this.emitUserChange(); })
}
.width('100%').margin({ top: 10 })
}
}
/* ── 模块级小工具(组件文件只导出 struct,所以这些都不导出) ── */
/** 异常 → 一句话。`ApiError.message` 已经是服务端中文文案(`ApiClient` 从 `{"error":…}` 取的)。 */
function businessMessage(e: Object): string {
const be = e as BusinessError;
if (be.message !== undefined && be.message.length > 0) {
return be.message;
}
return '未知原因';
}
/**
* 按给定质量压成 JPEG,返回内存字节。
*
* `quality` 是 **0~100 的整数**(SDK:`PackingOption.quality`,[0,100]),
* 而 `model/ImagePrep.ts` 里的质量是 0~1 的小数(照 WebUI 的 `toDataURL` 口径)——
* 换算只在这一处做。别在调用方各写一遍 `* 100`:两处一漂移
* (一边 85、一边 0.85)就会得到"压完比原图还大"这种看不懂的结果。
*/
async function packJpeg(pm: image.PixelMap, quality: number): Promise<ArrayBuffer> {
const packer: image.ImagePacker = image.createImagePacker();
try {
const opt: image.PackingOption = {
format: 'image/jpeg',
quality: Math.round(quality * 100)
};
/*
* ★★ 2026-09-21 修(编译器告警:`'packing' has been deprecated.`):
* `packer.packing(pm, opt)` → `packer.packToData(pm, opt)`。
*
* SDK 头文件的 `@useinstead` 直接给了这个目标:
* @since 6/8 @deprecated since 13 @useinstead image.ImagePacker#packToData
*
* 两者形状**逐字相同**(已比对 SDK 声明):
* packing(pm: PixelMap, options: PackingOption): Promise<ArrayBuffer>
* packToData(pm: PixelMap, options: PackingOption): Promise<ArrayBuffer>
* ⇒ 改名字就行,返回值/异常语义/调用方全部不变。
*/
return await packer.packToData(pm, opt);
} finally {
// ★ `release()` 也是 `Promise<void>`(SDK 两个重载:callback 版与 Promise 版)——
// 第 22 条判据把这一处抓出来了:不 await 的话它是一个"没人管的 promise",
// 而它出现在 `finally` 里,抛出的异常会替换掉原来的异常(真正的失败原因被吞掉)。
await packer.release();
}
}