三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【HarmonyOS】时间管理类APP:做成“自适应“

【HarmonyOS】时间管理类APP:做成“自适应“

这是我们开发的时间管理应用多端适配系列的第二篇。上一篇讲了三端的整体架构,这一篇讲更细的问题:当屏幕宽度变化时,布局该怎么跟着变? 答案是断点系统。但断点到底是什么、和设备类型有什么区别、又是怎么真正落到页面上呢?——这篇文章我们详细讲解。

先纠正一个问题:平板就该是某个固定样子吗?

很多人对多端适配的第一反应是:"手机长这样、平板长那样、电脑又长另一个样。"——给每种设备画一张设计稿,然后写死。

这种思路在多端适配会立刻碰壁。因为平板可以横屏竖屏切换,2in1 可以拖拽自由窗口缩放,折叠屏可以展开收起。同一个"平板",横屏时和竖屏时的差异可能很大。

所以本应用没有按设备类型写死布局密度,而是用了一个更底层的机制:断点。断点看的不是你是什么设备类型,而是你现在有多宽。

断点的定义:三道宽度门槛

entry/src/main/ets/utils/BreakpointUtils.ets 里,断点是这样定义的:

// 断点类型
export enum BreakpointType {SM = 'sm',  // 手机 320vp - 600vpMD = 'md',  // 平板 600vp - 840vpLG = 'lg'   // 2in1/大屏 840vp+
}

三个枚举值,对应三段宽度区间:

断点 宽度范围
SM 320vp - 600vp
MD 600vp - 840vp
LG 840vp+

断点的取值:一个泛型容器 + 一个 switch

有了断点类型,下一个问题是:不同断点下,某个属性(比如列数、间距、字号)该取什么值?项目里用了一个简洁的泛型方案。

首先是承载三端值的容器:

export class BreakpointValue<T> {sm: T;md: T;lg: T;constructor(sm: T, md: T, lg: T) {this.sm = sm;this.md = md;this.lg = lg;}
}

泛型 <T> 是关键——它让同一个容器能装数字、字符串、布尔值等任何类型。列数是 BreakpointValue<number>,最大宽度可以是 BreakpointValue<string>,是否显示侧边栏可以是 BreakpointValue<boolean>,都用同一套机制。

然后是取值函数:

// 根据断点获取值
export function getValueByBreakpoint<T>(breakpoint: BreakpointType, config: BreakpointValue<T>): T {switch (breakpoint) {case BreakpointType.SM:return config.sm;case BreakpointType.MD:return config.md;case BreakpointType.LG:return config.lg;default:return config.sm;}
}

一个 switch,根据当前断点返回对应值,default 兜底到 SM。

有了这两个基础件,具体的适配函数就是一行代码的事。比如徽章网格的列数:

// 获取徽章网格列数(徽章是等大方块,密度可比卡片更高)
export function getBadgeColumns(breakpoint: BreakpointType): number {return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(4, 6, 8));
}

SM 下 4 列、MD 下 6 列、LG 下 8 列。同目录下还有一组类似的函数:

// 获取列数配置
export function getGridColumns(breakpoint: BreakpointType): number {return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(4, 8, 12));
}// 获取卡片列数
export function getCardColumns(breakpoint: BreakpointType): number {return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(2, 3, 4));
}// 获取内边距
export function getPagePadding(breakpoint: BreakpointType): number {return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(16, 24, 32));
}// 获取卡片间距
export function getCardGap(breakpoint: BreakpointType): number {return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(12, 16, 20));
}

这里有一个值得注意的点:不同内容用了不同的列数策略。通用网格 getGridColumns 是 4/8/12,卡片 getCardColumns 是 2/3/4,徽章 getBadgeColumns 是 4/6/8。为什么不全用同一套?因为内容的"最小可用宽度"不同——一个卡片至少要能放下标题和几行文字,不能太窄;一个徽章只是一个 emoji 加几个字,可以排得更密。如果硬把徽章也用 4/8/12,那 LG 下 12 列徽章会挤成一团,每个小到看不清。

所以断点不是"一套参数走天下",而是"按内容特性分别配参"。这是断点系统用起来之后才会体会到的细节。

断点怎么"活"起来

前面定义的都还是"静态查询"——你给我一个断点,我告诉你列数。但断点系统真正要解决的问题是:屏幕宽度变化时,怎么自动感知并切换?

这就用到 mediaquery 了。项目里封装了一个 BreakpointManager 类来管理这件事:

import mediaquery from '@ohos.mediaquery';/*** 断点管理器类* 用于监听和管理设备断点变化*/
export class BreakpointManager {private currentBreakpoint: BreakpointType = BreakpointType.SM;private smListener: mediaquery.MediaQueryListener | null = null;private mdListener: mediaquery.MediaQueryListener | null = null;private lgListener: mediaquery.MediaQueryListener | null = null;private callbacks: ((breakpoint: BreakpointType) => void)[] = [];constructor() {this.initListeners();}private initListeners(): void {// 小屏 (手机)this.smListener = mediaquery.matchMediaSync('(320vp<=width<600vp)');this.smListener.on('change', (result: mediaquery.MediaQueryResult) => {if (result.matches) {this.updateBreakpoint(BreakpointType.SM);}});// 中屏 (平板)this.mdListener = mediaquery.matchMediaSync('(600vp<=width<840vp)');this.mdListener.on('change', (result: mediaquery.MediaQueryResult) => {if (result.matches) {this.updateBreakpoint(BreakpointType.MD);}});// 大屏 (2in1)this.lgListener = mediaquery.matchMediaSync('(840vp<=width)');this.lgListener.on('change', (result: mediaquery.MediaQueryResult) => {if (result.matches) {this.updateBreakpoint(BreakpointType.LG);}});}private updateBreakpoint(breakpoint: BreakpointType): void {if (this.currentBreakpoint !== breakpoint) {this.currentBreakpoint = breakpoint;this.callbacks.forEach(callback => callback(breakpoint));}}getCurrentBreakpoint(): BreakpointType {return this.currentBreakpoint;}onChange(callback: (breakpoint: BreakpointType) => void): void {this.callbacks.push(callback);}...
}

核心是 initListeners() 里的三段。它用 mediaquery.matchMediaSync 注册了三个媒体查询监听器,分别对应三个宽度区间:

  • (320vp<=width<600vp) 命中 → 当前是 SM
  • (600vp<=width<840vp) 命中 → 当前是 MD
  • (840vp<=width) 命中 → 当前是 LG

每当窗口宽度跨过某个门槛,对应的监听器就会触发 'change' 事件,result.matchestrue 表示当前宽度落在这个区间内。随后 updateBreakpoint 被调用,它做了两件事:更新 currentBreakpoint,然后遍历所有注册的回调函数通知它们。

updateBreakpoint 里有一个细节值得留意:if (this.currentBreakpoint !== breakpoint) 这个判断。它确保只在断点真正变化时才触发回调,避免重复通知。比如窗口在 500vp-550vp 之间来回抖动,只要没跨过 600vp 这个门槛,就不会产生任何回调。

断点怎么传给页面:AppStorage 全局同步

BreakpointManager 能感知断点变化了,但它只是一个实例,页面怎么拿到当前断点?项目里的做法是在 MainPage 初始化时把断点值同步到全局 AppStorage,所有页面通过 @StorageLink 读取。

MainPageaboutToAppear 里做了这件事:

  private breakpointManager: BreakpointManager = getBreakpointManager()async aboutToAppear(): Promise<void> {try {this.context = getContext(this) as common.UIAbilityContextthis.is2In1 = is2In1Device()this.fontScale = getFontScale()// 初始化断点并同步到全局this.currentBreakpoint = this.breakpointManager.getCurrentBreakpoint()AppStorage.setOrCreate('currentBreakpoint', this.currentBreakpoint)// 监听断点变化,实时同步到全局 AppStorage 供各页面响应this.breakpointManager.onChange((bp: BreakpointType) => {this.currentBreakpoint = bpAppStorage.setOrCreate('currentBreakpoint', bp)})// 初始化分析子Tab为0AppStorage.setOrCreate('analysisSubTab', 0)// 存储字体缩放系数到全局AppStorage.setOrCreate('fontScale', this.fontScale)await this.initializeApp()} catch (error) {const err = error as Errorthis.initError = `初始化错误: ${err.message}`}}

关键是这两步:

  1. 初始化时读一次this.breakpointManager.getCurrentBreakpoint() 拿到当前断点,存入 AppStorage.setOrCreate('currentBreakpoint', ...)
  2. 注册回调持续同步this.breakpointManager.onChange(...) 注册一个回调,断点一变就更新 AppStorage

这样,任何页面只要声明 @StorageLink('currentBreakpoint'),就能自动拿到最新断点,而且断点变化时 UI 会自动刷新——因为 @StorageLink 是双向绑定的,AppStorage 里的值一变,所有绑定的组件都会重新渲染。

断点在本应用中的实现案例:成就徽章网格的列数随断点变化

前面讲了机制,现在看一个真正落到页面上的例子:成就系统的徽章网格。

徽章是等大的小方块(一个 emoji 图标 + 名称 + 稀有度星级),天生适合用网格排列。在 entry/src/main/ets/components/AchievementCard.ets 里,它这样接入断点:

import { scaleFontSize, BreakpointType, getBadgeColumns } from '../utils/BreakpointUtils'@Component
export struct AchievementCard {@Prop achievements: Achievement[] = []@Prop challenges: Challenge[] = []@StorageLink('fontScale') fontScale: number = 1.0@StorageLink('currentBreakpoint') currentBreakpoint: BreakpointType = BreakpointType.SM// 辅助方法:应用字体缩放private fs(size: number): number {return scaleFontSize(size)}// 根据当前断点生成徽章网格的 columnsTemplate 字符串// SM(手机)=4列、MD(平板)=6列、LG(电脑)=8列,让徽章密度随屏幕变宽而提升private getBadgeColumnsTemplate(): string {const cols: number = getBadgeColumns(this.currentBreakpoint)const fr: string[] = []for (let i = 0; i < cols; i++) {fr.push('1fr')}return fr.join(' ')}build() {Column() {// 卡片标题 - 使用左侧色条设计Row() {Row().width(4).height(20).backgroundColor($r('app.color.high_quality_color')).borderRadius(2)Text('时间成就系统').fontSize(this.fs(17)).fontWeight(FontWeight.Bold).fontColor($r('app.color.text_primary')).margin({ left: 10 })}.width('100%').margin({ bottom: 16 })// 已解锁徽章展示Text('已解锁徽章').fontSize(this.fs(16)).fontWeight(FontWeight.Medium).fontColor($r('app.color.text_primary')).width('100%').margin({ bottom: 12 })if (this.getUnlockedAchievements().length > 0) {// 徽章网格Grid() {ForEach(this.getUnlockedAchievements(), (achievement: Achievement, index: number) => {GridItem() {this.AchievementBadge(achievement, true)}}, (achievement: Achievement) => achievement.id)}.columnsTemplate(this.getBadgeColumnsTemplate()).rowsGap(12).columnsGap(12).width('100%').margin({ bottom: 24 })} else {...}...}}
}

接入断点的关键是三行:

第一行,组件声明里绑定全局断点:

@StorageLink('currentBreakpoint') currentBreakpoint: BreakpointType = BreakpointType.SM

第二行,把断点转成列数:

private getBadgeColumnsTemplate(): string {const cols: number = getBadgeColumns(this.currentBreakpoint)const fr: string[] = []for (let i = 0; i < cols; i++) {fr.push('1fr')}return fr.join(' ')
}

这个方法把 getBadgeColumns 返回的数字(4/6/8)转成 ArkUI Grid 能识别的 columnsTemplate 字符串格式('1fr 1fr 1fr 1fr')。

第三行,在 Grid 上应用:

.columnsTemplate(this.getBadgeColumnsTemplate())

就这三步,徽章网格就会随断点自动调整列数。当窗口宽度从 500vp 拖到 900vp,你会看到徽章从 4 列变成 8 列——不需要重新加载数据,不需要手动刷新,@StorageLink 的响应式绑定会自动触发重新渲染。

手机端:4列
手机端,SM断点
竖屏平板:
平板竖屏
平板横屏:
平板横屏
鸿蒙PC:
鸿蒙PC

七、断点 vs 设备类型:两套机制,各管各的

读到这里可能会有一个疑问:项目里不是还有 getDeviceType()is2In1Device() 这些按设备类型判断的函数吗?它们和断点是什么关系?

答案是:两套机制并存,但分工不同

断点管的是尺寸适配。列数、间距、内边距这些会随宽度平滑变化的属性。它响应的是"现在有多宽"。

设备类型管的是交互范式。底部 Tab 还是侧边栏、要不要开 hover、要不要显示键盘提示。它响应的是"你是什么设备"。

一个具体的例子:平板横屏时,断点可能变成 LG(宽度够大),徽章会排成 8 列;但导航仍然是底部 Tab 而非侧边栏,因为设备类型还是 tablet,而侧边栏只在 2in1 时出现。这两件事互不干扰。宽度够大就让内容更多,但交互范式不变。

这种分工是合理的。尺寸适配跟着宽度走,因为宽度是连续的、可变的;交互范式跟着设备类型走,因为键鼠/触摸的差异是离散的、稳定的。

监听器要记得释放

BreakpointManager 里注册了三个 mediaquery 监听器,每个都通过 on('change', ...) 挂了回调。这些监听器是长生命周期的,如果创建了多个 BreakpointManager 实例又不释放,监听器会越积越多,导致回调被重复触发甚至内存泄漏。

项目里的处理方式是用一个全局单例,避免重复创建:

// 全局断点管理器实例
let globalBreakpointManager: BreakpointManager | null = null;export function getBreakpointManager(): BreakpointManager {if (!globalBreakpointManager) {globalBreakpointManager = new BreakpointManager();}return globalBreakpointManager;
}

getBreakpointManager() 保证全 APP 只有一个实例。而 BreakpointManager 本身也提供了销毁方法:

  destroy(): void {if (this.smListener) {this.smListener.off('change');}if (this.mdListener) {this.mdListener.off('change');}if (this.lgListener) {this.lgListener.off('change');}this.callbacks = [];}

三个 off('change') 逐个解绑。

小结

断点系统看起来只是几个 ifswitch,但它背后是一套完整的思路转变。

下一篇我们会聚焦设备类型那一侧,看 MainPage 是怎么用 is2In1Device() 在底部 Tab 和侧边栏之间做导航范式切换的。

← 返回列表