这是我们开发的时间管理应用多端适配系列的第二篇。上一篇讲了三端的整体架构,这一篇讲更细的问题:当屏幕宽度变化时,布局该怎么跟着变? 答案是断点系统。但断点到底是什么、和设备类型有什么区别、又是怎么真正落到页面上呢?------这篇文章我们详细讲解。
先纠正一个问题:平板就该是某个固定样子吗?
很多人对多端适配的第一反应是:"手机长这样、平板长那样、电脑又长另一个样。"------给每种设备画一张设计稿,然后写死。
这种思路在多端适配会立刻碰壁。因为平板可以横屏竖屏切换,2in1 可以拖拽自由窗口缩放,折叠屏可以展开收起。同一个"平板",横屏时和竖屏时的差异可能很大。
所以本应用没有按设备类型写死布局密度,而是用了一个更底层的机制:断点。断点看的不是你是什么设备类型,而是你现在有多宽。
断点的定义:三道宽度门槛
在 entry/src/main/ets/utils/BreakpointUtils.ets 里,断点是这样定义的:
typescript
// 断点类型
export enum BreakpointType {
SM = 'sm', // 手机 320vp - 600vp
MD = 'md', // 平板 600vp - 840vp
LG = 'lg' // 2in1/大屏 840vp+
}
三个枚举值,对应三段宽度区间:
| 断点 | 宽度范围 |
|---|---|
| SM | 320vp - 600vp |
| MD | 600vp - 840vp |
| LG | 840vp+ |
断点的取值:一个泛型容器 + 一个 switch
有了断点类型,下一个问题是:不同断点下,某个属性(比如列数、间距、字号)该取什么值?项目里用了一个简洁的泛型方案。
首先是承载三端值的容器:
typescript
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>,都用同一套机制。
然后是取值函数:
typescript
// 根据断点获取值
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。
有了这两个基础件,具体的适配函数就是一行代码的事。比如徽章网格的列数:
typescript
// 获取徽章网格列数(徽章是等大方块,密度可比卡片更高)
export function getBadgeColumns(breakpoint: BreakpointType): number {
return getValueByBreakpoint(breakpoint, new BreakpointValue<number>(4, 6, 8));
}
SM 下 4 列、MD 下 6 列、LG 下 8 列。同目录下还有一组类似的函数:
typescript
// 获取列数配置
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 类来管理这件事:
typescript
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.matches 为 true 表示当前宽度落在这个区间内。随后 updateBreakpoint 被调用,它做了两件事:更新 currentBreakpoint,然后遍历所有注册的回调函数通知它们。
updateBreakpoint 里有一个细节值得留意:if (this.currentBreakpoint !== breakpoint) 这个判断。它确保只在断点真正变化时才触发回调,避免重复通知。比如窗口在 500vp-550vp 之间来回抖动,只要没跨过 600vp 这个门槛,就不会产生任何回调。
断点怎么传给页面:AppStorage 全局同步
BreakpointManager 能感知断点变化了,但它只是一个实例,页面怎么拿到当前断点?项目里的做法是在 MainPage 初始化时把断点值同步到全局 AppStorage,所有页面通过 @StorageLink 读取。
MainPage 的 aboutToAppear 里做了这件事:
typescript
private breakpointManager: BreakpointManager = getBreakpointManager()
async aboutToAppear(): Promise<void> {
try {
this.context = getContext(this) as common.UIAbilityContext
this.is2In1 = is2In1Device()
this.fontScale = getFontScale()
// 初始化断点并同步到全局
this.currentBreakpoint = this.breakpointManager.getCurrentBreakpoint()
AppStorage.setOrCreate('currentBreakpoint', this.currentBreakpoint)
// 监听断点变化,实时同步到全局 AppStorage 供各页面响应
this.breakpointManager.onChange((bp: BreakpointType) => {
this.currentBreakpoint = bp
AppStorage.setOrCreate('currentBreakpoint', bp)
})
// 初始化分析子Tab为0
AppStorage.setOrCreate('analysisSubTab', 0)
// 存储字体缩放系数到全局
AppStorage.setOrCreate('fontScale', this.fontScale)
await this.initializeApp()
} catch (error) {
const err = error as Error
this.initError = `初始化错误: ${err.message}`
}
}
关键是这两步:
- 初始化时读一次 :
this.breakpointManager.getCurrentBreakpoint()拿到当前断点,存入AppStorage.setOrCreate('currentBreakpoint', ...)。 - 注册回调持续同步 :
this.breakpointManager.onChange(...)注册一个回调,断点一变就更新AppStorage。
这样,任何页面只要声明 @StorageLink('currentBreakpoint'),就能自动拿到最新断点,而且断点变化时 UI 会自动刷新------因为 @StorageLink 是双向绑定的,AppStorage 里的值一变,所有绑定的组件都会重新渲染。
断点在本应用中的实现案例:成就徽章网格的列数随断点变化
前面讲了机制,现在看一个真正落到页面上的例子:成就系统的徽章网格。
徽章是等大的小方块(一个 emoji 图标 + 名称 + 稀有度星级),天生适合用网格排列。在 entry/src/main/ets/components/AchievementCard.ets 里,它这样接入断点:
typescript
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 {
...
}
...
}
}
}
接入断点的关键是三行:
第一行,组件声明里绑定全局断点:
typescript
@StorageLink('currentBreakpoint') currentBreakpoint: BreakpointType = BreakpointType.SM
第二行,把断点转成列数:
typescript
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 上应用:
typescript
.columnsTemplate(this.getBadgeColumnsTemplate())
就这三步,徽章网格就会随断点自动调整列数。当窗口宽度从 500vp 拖到 900vp,你会看到徽章从 4 列变成 8 列------不需要重新加载数据,不需要手动刷新,@StorageLink 的响应式绑定会自动触发重新渲染。
手机端:4列
竖屏平板:
平板横屏:
鸿蒙PC: 
七、断点 vs 设备类型:两套机制,各管各的
读到这里可能会有一个疑问:项目里不是还有 getDeviceType()、is2In1Device() 这些按设备类型判断的函数吗?它们和断点是什么关系?
答案是:两套机制并存,但分工不同。
断点管的是尺寸适配。列数、间距、内边距这些会随宽度平滑变化的属性。它响应的是"现在有多宽"。
设备类型管的是交互范式。底部 Tab 还是侧边栏、要不要开 hover、要不要显示键盘提示。它响应的是"你是什么设备"。
一个具体的例子:平板横屏时,断点可能变成 LG(宽度够大),徽章会排成 8 列;但导航仍然是底部 Tab 而非侧边栏,因为设备类型还是 tablet,而侧边栏只在 2in1 时出现。这两件事互不干扰。宽度够大就让内容更多,但交互范式不变。
这种分工是合理的。尺寸适配跟着宽度走,因为宽度是连续的、可变的;交互范式跟着设备类型走,因为键鼠/触摸的差异是离散的、稳定的。
监听器要记得释放
BreakpointManager 里注册了三个 mediaquery 监听器,每个都通过 on('change', ...) 挂了回调。这些监听器是长生命周期的,如果创建了多个 BreakpointManager 实例又不释放,监听器会越积越多,导致回调被重复触发甚至内存泄漏。
项目里的处理方式是用一个全局单例,避免重复创建:
typescript
// 全局断点管理器实例
let globalBreakpointManager: BreakpointManager | null = null;
export function getBreakpointManager(): BreakpointManager {
if (!globalBreakpointManager) {
globalBreakpointManager = new BreakpointManager();
}
return globalBreakpointManager;
}
getBreakpointManager() 保证全 APP 只有一个实例。而 BreakpointManager 本身也提供了销毁方法:
typescript
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') 逐个解绑。
小结
断点系统看起来只是几个 if 和 switch,但它背后是一套完整的思路转变。
下一篇我们会聚焦设备类型那一侧,看 MainPage 是怎么用 is2In1Device() 在底部 Tab 和侧边栏之间做导航范式切换的。