大多数技术文章讲的是“怎么实现一个功能”,但本文换个角度:如何把一份600行的设计规范文档,转化成可运行的ArkUI代码。项目“本地扫描仪”包含文件扫描、证件照、OCR三个模块。最初版本在UI上问题明显——证件照页面同时铺了8到10个交互元素,字号从11sp到22sp出现了8种,按钮主次不分。于是我们编写了完整的设计规范文档,逐页重构,并记录下这个过程:设计规范怎么写、代码怎么落地、踩了哪些坑。
一、问题诊断:先量化再动手
重构前,我们先做了两件事:统计每个页面的交互元素数量,以及统计代码中所有fontSize取值。证件照页面最严重——背景色选择、尺寸选择、自定义开关、RGB输入框、导出按钮全部展开在同一屏内。字号上,同级元素(区块标题)同时出现了13、15、16三种取值,典型的“边做边加”导致的不一致。
二、建立字体系统:常量统一管理
解决字号混乱的第一步是建立统一的Typography Scale。我们定义了7级字体层级:H1 Display 24sp Bold(页面唯一大标题)、H2 Section 18sp SemiBold(功能区块标题)、H3 Card 16sp Medium(卡片内标题)、Body Large 15sp Regular(按钮文字/重要正文)、Body 14sp Regular(副标题/说明文字)、Caption 12sp Regular(辅助提示)、Tab Label 10sp Regular(底部Tab文字)。比原来的8种取值少一种,但覆盖所有场景。关键变更:区块标题统一为18sp(取代13/15/16混用),极小提示从11sp提升到12sp。
代码落地使用常量统一管理。创建AppTypography.ets文件,定义Typography对象:- export const Typography = {
- H1: { size: 24, weight: FontWeight.Bold },
- H2: { size: 18, weight: FontWeight.SemiBold },
- H3: { size: 16, weight: FontWeight.Medium },
- BodyLg: { size: 15, weight: FontWeight.Normal },
- Body: { size: 14, weight: FontWeight.Normal },
- Caption: { size: 12, weight: FontWeight.Normal },
- Tab: { size: 10, weight: FontWeight.Normal }
- };
复制代码 使用时直接引用常量,不再手写数字:- Text('文件扫描')
- .fontSize(Typography.H1.size)
- .fontWeight(Typography.H1.weight)
复制代码 好处是改字号只需改一处常量,坏处是初期迁移工作量大,每个页面的Text组件都要改。
三、证件照页面重构:渐进式披露
证件照页面是重构重点,8到10个交互元素压缩到3个。核心思路是渐进式披露:默认只展示最常用选项,高级选项折叠收起。阶段划分:阶段1(无图片时)只展示选图入口;阶段2(有图片后)展示预览+背景色+尺寸+导出;阶段3(自定义展开)用户主动展开高级选项。
背景色选择器从3个文字chip改为圆形色块选择器:- Row({ space: 12 }) {
- ForEach(BgColors, (c: BgColorItem) => {
- Column() {
- Circle()
- .width(44).height(44)
- .fill(c.color)
- .stroke(this.selectedBg === c.id ? '#007DFF' : 'transparent')
- .strokeWidth(3)
- .scale({ x: this.selectedBg === c.id ? 1.08 : 1.0 })
- Text(c.label)
- .fontSize(Typography.Caption.size)
- .fontColor('#999')
- }
- })
- }
复制代码 尺寸选择器从4个平铺chip改为下拉选择器:- Column() {
- Row() {
- Text(`当前:${this.selectedSize.label}`)
- .fontSize(Typography.Body.size)
- Image($r('app.media.ic_arrow_down'))
- .rotate({ angle: this.showSizeDropdown ? 180 : 0 })
- }
- if (this.showSizeDropdown) {
- ForEach(SizeOptions, (s: SizeOption) => {
- Text(`${s.label} ${s.width}×${s.height}mm`)
- .onClick(() => {
- this.selectedSize = s;
- this.showSizeDropdown = false;
- })
- })
- }
- }
复制代码
四、扫描结果页重构:按钮层级
扫描结果页原图/增强切换、导出PDF、导出图片、分享四个操作平铺,视觉权重相同。解决方案是分层+ActionSheet合并。模式切换采用Segmented Control样式:- Row() {
- ForEach(['原图', '增强'], (mode: string) => {
- Text(mode)
- .fontSize(14)
- .fontColor(this.currentMode === mode ? '#fff' : '#999')
- .backgroundColor(this.currentMode === mode ? '#007DFF' : 'transparent')
- .borderRadius(18)
- .padding({ left: 20, right: 20, top: 8, bottom: 8 })
- .onClick(() => { this.currentMode = mode; })
- })
- }
复制代码 导出按钮用主操作+ActionSheet:- Button('导出', { type: ButtonType.Capsule })
- .height(52)
- .width('100%')
- .backgroundColor('#007DFF')
- .onClick(() => {
- ActionSheet.show({
- items: [
- { title: '导出为 PDF' },
- { title: '导出高清图片' },
- { title: '分享给好友' }
- ]
- })
- })
复制代码 主操作按钮用实心填充52vp高度,次要操作用描边样式44vp。三种导出方式收进一个弹窗,首页只保留1个导出按钮。
五、响应式布局:断点系统
我们定义了四个断点:SM ≤600vp(手机竖屏,底部Tab)、MD 600~840vp(手机横屏,底部Tab)、LG 840~1200vp(平板竖屏,左侧Tab)、XL ≥1200vp(平板横屏,左侧Tab)。关键组件在不同断点下布局差异:- if (this.breakpoint >= Breakpoint.LG) {
- // 平板:左右分栏
- Row() {
- Column() { this.SwiperPreview() }.layoutWeight(3)
- Column() { this.OperationPanel() }.layoutWeight(2)
- }
- } else {
- // 手机:上下堆叠
- Scroll() {
- Column() {
- this.SwiperPreview()
- this.OperationPanel()
- }
- }
- }
复制代码 断点检测使用系统API:- import { display } from '@kit.ArkUI';
- @Entry
- @Component
- struct Index {
- @StorageProp('breakpoint') breakpoint: string = 'sm';
- aboutToAppear() {
- const w = display.getDefaultDisplaySync().width;
- this.updateBreakpoint(w);
- display.on('displayChange', () => {
- const nw = display.getDefaultDisplaySync().width;
- this.updateBreakpoint(nw);
- });
- }
- }
复制代码
六、动画规范
全局动画参数:证件照选图成功后的进场动画是预览图淡入+上滑:- Column() {
- Image(this.selectedImage)
- .opacity(this.showPreview ? 1 : 0)
- .translate({ y: this.showPreview ? 0 : 10 })
- .animation({ duration: 300, curve: Curve.EaseOut })
- }
复制代码 Tab切换的弹性缩放:- Image(tab.icon)
- .scale({
- x: this.activeTab === tab.id ? 1.08 : 1.0,
- y: this.activeTab === tab.id ? 1.08 : 1.0
- })
- .animation({
- duration: 200,
- curve: springMotion(0.5, { damping: 0.7 })
- })
复制代码 springMotion参数response 0.5秒,damping 0.7,产生轻微弹跳后迅速收敛的效果。
七、踩坑记录
坑1:fontSize常量的类型问题。ArkUI的fontSize接受number|string|Resource。如果常量定义为{size:24},类型推断为number,但某些组件(如Button)的fontSize需要string或Resource。修复:常量定义为number,使用时转string或定义为Resource:.fontSize(Typography.H1.size.toString())或H1: { size: $r('app.integer.font_h1') }。Resource方式更符合规范,但需要额外资源文件。
坑2:ForEach的keyGenerator。证件照背景色选择器用ForEach渲染色块,如果不提供keyGenerator,列表项身份识别不准确,切换选中态时动画会闪烁。必须提供keyGenerator:ForEach(BgColors, (c) => {...}, (c) => c.id)。
坑3:ActionSheet在不同设备上的表现。手机上ActionSheet从底部滑入,高度自适应;平板上ActionSheet宽度会撑满屏幕,因为平板默认使用Dialog样式。修复:平板端改用自定义Popup或DropdownMenu。
坑4:动画时长与帧率的关系。100ms微交互在60fps设备上约6帧,在120fps上约12帧,更平滑但可能感觉“太慢”。HarmonyOS的动画曲线在不同帧率下会自动插值,无需手动处理。
坑5:设计规范的执行一致性。最难不是写规范,而是保证每个开发者都遵守。代码审查时发现有人在新页面里又手写fontSize(16)而不是引用Typography.H3。修复:ESLint规则禁止直接使用数字字号:"harmonyos/no-hardcoded-font-size": "error"。
八、性能数据
代码行数反而减少了约15%,因为消除了大量重复的字号、颜色、间距硬编码。
九、核心思想
回顾整个过程,最有价值的三个决策:先量化问题再动手,不是“我觉得字号乱”而是“统计出8种取值”,数字比直觉更有说服力;渐进式披露解决信息过载,证件照页面从10个元素压缩到3个,不是删除功能而是折叠;常量化消除不一致,Typography常量+ESLint规则从源头杜绝字号混乱。核心差异:传统重构靠开发者的审美直觉,本方案靠600行设计文档+常量体系,设计文档是代码的上游输入,代码是设计文档的下游实现。 |