轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适
轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适配
文章目录
背景介绍
在之前的开发中,我们为“轻规划”(AeroPlan)自研了一套高强度的 Markdown AST(抽象语法树)解析渲染器,用以把 AI 导师智能输出的 Markdown 文本优雅地格式化为“曼陀罗九宫格”全景大图。
但在实际体验中,折叠屏(Foldable Device)为我们提出了更严苛的屏幕一多适配挑战。
折叠屏拥有典型的三种屏幕姿态:
- 折叠态(直屏,小宽度):用户单手持握,屏幕较窄,九宫格强行排布 3 列会导致文字严重挤压甚至重叠。
- 展开态(大屏,正方形):九宫格完全舒展,雷达图并列排版最合适。
- 半折叠态(悬停态):上部屏幕显示愿景信,下半部分键盘或操作区。
如果我们的 Markdown AST 渲染器只会死板地拉伸,在屏幕宽度骤变时,界面布局就会变形崩溃。
今天,我们将聚焦 Markdown 渲染层,利用 ArkUI 的 一多动态媒体查询(Media Query) 与 栅格断点(Breakpoints),实战解构折叠屏状态下的自适应渲染劫持方案。
1. 架构纵览:折叠屏姿态感知与 UI 自适应重构管线
在折叠屏展开收起时,系统底层的窗口大小会发生物理跳变。我们通过媒体查询组件注册视口监听,无缝改变渲染网格列数与图表排列。职责划分如下:

1.1 核心设计考量
为了在折叠屏设备上提供极致的用户体验,系统必须具备在毫秒级内感知屏幕形态变化并作出响应的能力。本方案的设计核心是将“物理屏幕监测”与“组件渲染劫持”完全解耦:
- 感知层(Sensor Layer):由
ScreenOrientationTracker负责监听底层的屏幕尺寸广播,消除冗余计算,将宽度断点转化为清晰的状态指令(SMALL、LARGE)。 - 状态总线(State Bus):基于 HarmonyOS 的全局共享存储组件
AppStorage,进行瞬时状态的分发,从而使得处于任何层级的 UI 组件均能即时响应。 - 渲染层(Render Layer):基于 AST 解析出来的虚拟节点(Virtual Nodes),通过渲染劫持器进行动态逻辑分流。当断点判定为小屏时,自动采用高扩展性的滑块卡片容器;当判定为大屏时,重构成高信息密度的物理栅格矩阵。
2. 媒体查询注册:监听折叠屏物理宽度跳变
在 HarmonyOS 中,我们使用 @ohos.mediaquery 模块,通过设定视口像素宽度断点来识别设备状态。
2.1 物理宽度断点定义与多端博弈
在屏幕一多适配中,断点(Breakpoint)的选择至关重要。根据系统的设计规范,600vp 是手机直屏与折叠屏展开态/平板的关键分界线。
- 宽度 < 600vp:通常对应手机单屏(如普通手机、折叠屏合上后的外屏)。此时屏幕资源极度受限,UI 设计必须采用垂直流式布局或横向滚动机制。
- 宽度 >= 600vp:对应折叠屏展开后的内屏、小型平板或横屏手机。此时可用宽度大幅增加,应支持多列多维度的网格排版,以最大化屏幕利用率,防止页面过度空旷。
2.2 折叠屏姿态监测核心代码
我们构建一个专门的监测服务,在应用初始化时开启监听,并在销毁时注销,以防止内存泄露。
// 引入 HarmonyOS 系统媒体查询服务,用于实现对视口尺寸变化及断点的精准监听
import mediaquery from '@ohos.mediaquery';
/**
* 屏幕方向及断点监测器类,封装系统底层的媒体查询逻辑
*/
export class ScreenOrientationTracker {
// 声明系统媒体查询监听对象,使用 600vp 作为大小分屏的分界线
// matchMediaSync 将会持续捕获当前的窗口宽度是否满足“小于 600 虚拟像素 (vp)”的条件
private listener: mediaquery.MediaQueryListener = mediaquery.matchMediaSync('(width < 600vp)');
/**
* 注册屏幕监听器
* 在主入口页面(或 Ability)的生命周期钩子中调用,开启全局视口检测
*/
public registerScreenTracker(): void {
// 1. 注册监听回调函数,当设备的视口宽度跨越 600vp 临界线时,将触发 handleQueryResult 回调
this.listener.on('change', (mediaQueryResult: mediaquery.MediaQueryResult) => {
this.handleQueryResult(mediaQueryResult);
});
// 2. 首次注册时主动执行一次初始化查询,防止由于冷启动未触发 change 事件而导致 UI 状态为空
this.handleQueryResult(this.listener);
}
/**
* 注销监听器
* 必须在页面销毁或应用退出时调用,否则底层持有的回调引用会导致严重的内存回收阻碍
*/
public unregister(): void {
// 移除对应的事件监听,释放闭包占用的内存资源
this.listener.off('change');
}
/**
* 媒体查询状态处理逻辑
* @param result 媒体查询返回的匹配结果对象
*/
private handleQueryResult(result: mediaquery.MediaQueryResult): void {
// 判定当前设备宽度是否匹配我们设定的条件:即当前屏幕宽度是否小于 600vp
if (result.matches) {
// 匹配成功:说明当前设备处于手机直屏态、或折叠屏的折叠状态(外屏)
// 将全局共享状态 'deviceScreenSize' 更新为 'SMALL',以通知渲染器降级排版
AppStorage.setOrCreate('deviceScreenSize', 'SMALL');
console.info("ScreenTracker", "Device physical state switched: SMALL SCREEN (< 600vp)");
} else {
// 匹配失败:说明当前屏幕宽度大于等于 600vp,处于折叠屏展开态、横屏态或平板设备
// 将全局共享状态 'deviceScreenSize' 更新为 'LARGE',以启用完整矩阵排版
AppStorage.setOrCreate('deviceScreenSize', 'LARGE');
console.info("ScreenTracker", "Device physical state switched: LARGE SCREEN (>= 600vp)");
}
}
}
3. 渲染劫持实现:自适应 Markdown 3x3 矩阵切换
在我们的 NineGridBalanceMatrix 核心组件中,我们需要劫持 @StorageLink('deviceScreenSize') 状态。在直屏(SMALL)下,3x3 的九宫格会被自动重塑为**“自适应横向滑动的卡片流”**,以解决文字被物理挤压的顽疾。
3.1 渲染劫持与 AST 数据定义
渲染劫持(Render Hijack)是一种底层渲染技术,它允许我们在组件实际挂载并生成物理节点之前,拦截数据并根据当前的物理形态动态决定输出的 ArkUI 组件树结构。
为了配合 Markdown AST,我们先定义用于在九宫格中填充的单元格结构:
/**
* 曼陀罗九宫格单元格的数据结构模型,用于从 Markdown 节点解析后进行渲染
*/
export class MatrixCell {
public id: string = ''; // 单元格唯一标识符
public title: string = ''; // 单元格主要标题
public content: string = ''; // 解析出的富文本或纯文本内容
public x: number = 0; // 矩阵行坐标位置 (0, 1, 2)
public y: number = 0; // 矩阵列坐标位置 (0, 1, 2)
public items: string[] = []; // 行动项列表
}
3.2 自适应渲染重构实现
import { MatrixCell } from './MatrixModel';
/**
* 具备动态渲染劫持功能的折叠屏自适应曼陀罗矩阵组件
*/
@Component
export struct AdaptiveNineGrid {
// 通过 StorageLink 单向或双向绑定全局状态 deviceScreenSize,
// 当 ScreenOrientationTracker 更新 AppStorage 时,此状态将响应式触发当前组件的 rebuild
@StorageLink('deviceScreenSize') deviceScreenSize: string = 'SMALL';
// 承载经过 Markdown AST 解析器转换后的九宫格结构化数据源
@State cells: MatrixCell[] = [];
build() {
Column() {
// 使用条件渲染实现动态劫持逻辑
if (this.deviceScreenSize === 'LARGE') {
// --- LARGE 断点:大屏/展开态 ---
// 渲染完整的高保真 3x3 曼陀罗矩阵,完美展示规划全局图景
Grid() {
ForEach(this.cells, (cell: MatrixCell) => {
GridItem() {
// 渲染具体的宫格组件
this.buildGridCell(cell)
}
// 依据 AST 中解析出的空间物理布局,精确锁定其行列占用,实现错落有致的布局
.rowStart(cell.x)
.columnStart(cell.y)
}, (item: MatrixCell) => item.id)
}
// 设置 3 列,每列平分可用空间
.columnsTemplate('1fr 1fr 1fr')
// 设置 3 行,每行平分可用空间
.rowsTemplate('1fr 1fr 1fr')
.columnsGap(8) // 物理格间水平缝隙
.rowsGap(8) // 物理格间垂直缝隙
.width('100%')
.aspectRatio(1.2) // 针对宽屏锁死黄金横宽比,避免在超宽屏幕上无限垂直拉伸
.transition(TransitionEffect.OPACITY.animation({ duration: 300 })) // 切换姿态时渐显过渡
} else {
// --- SMALL 断点:窄屏/折叠态/手机直屏 ---
// 渲染劫持:由于单屏状态宽度不足 200vp,无法容纳 3 列文字。
// 将九宫格强制降级为符合手机操作习惯的“横向滑动的卡片流列表”。
List({ space: 12 }) {
ForEach(this.cells, (cell: MatrixCell) => {
ListItem() {
// 渲染适配窄屏的长条滑动卡片
this.buildListCell(cell)
}
}, (item: MatrixCell) => item.id)
}
.width('100%')
.height(190) // 锁死横轴滑动区高度,确保下方其他规划详情内容能够合理排布
.listDirection(Axis.Horizontal) // 改为横向滚动排布
.edgeEffect(EdgeEffect.Spring) // 设置回弹动画,提升滑动跟手流畅度
.transition(TransitionEffect.translate({ x: -50 }).combine(TransitionEffect.OPACITY)) // 进场平移渐变
}
}
.width('100%')
.padding(16) // 组件外围填充,避免贴边影响视觉体验
}
/**
* 渲染子程序:大屏状态下的网格单元
*/
@Builder
buildGridCell(cell: MatrixCell) {
Column() {
// 头部标题渲染
Text(cell.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Divider().color('#BDC3C7').strokeWidth(0.5).margin({ top: 4, bottom: 4 })
// 富文本行动项目展示
Column() {
ForEach(cell.items.slice(0, 3), (item: string) => {
Text(`• ${item}`)
.fontSize(11)
.fontColor('#34495E')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ bottom: 2 })
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
.width('100%')
.height('100%')
.padding(10)
.backgroundColor('#FDFEFE')
.borderRadius(8)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.05)', offsetX: 0, offsetY: 2 })
}
/**
* 渲染子程序:窄屏下的横向列表长卡片
*/
@Builder
buildListCell(cell: MatrixCell) {
Column() {
Row() {
// 左侧指示性标识
Circle({ width: 8, height: 8 }).fill('#3498DB').margin({ right: 6 })
Text(cell.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
}
.width('100%')
Divider().color('#BDC3C7').strokeWidth(0.5).margin({ top: 6, bottom: 6 })
// 滑动卡片显示更多的文字细节,并使用高容错布局
Scroll() {
Column() {
ForEach(cell.items, (item: string) => {
Row() {
Text('▪ ')
.fontSize(12)
.fontColor('#3498DB')
Text(item)
.fontSize(12)
.fontColor('#5D6D7E')
.layoutWeight(1) // 自动折行,保证窄屏空间文字的阅读连续性
}
.alignItems(VerticalAlign.Top)
.width('100%')
.margin({ bottom: 4 })
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
}
.width(180) // 严格限宽,强制确保每一个横向卡片拥有足够的横向显示区域,杜绝文字重叠
.height('100%')
.padding(12)
.backgroundColor('#FDFEFE')
.borderRadius(10)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 3 })
}
}
4. 极客避坑:折叠瞬间状态树丢失与页面生命周期重建
在折叠屏“啪”地合上瞬间,窗口大小骤变,有些鸿蒙设备为了重新排版,会强制销毁当前的页面组件并执行 页面生命周期重建(Component Destroy & Rebuild)。
如果不做数据防丢设计,用户在 Markdown 输入框里刚写下一半的数据会在折叠的刹那直接消失。
4.1 避坑指南:持久化草稿热备份
我们必须在前台 Markdown 输入组件的 onChange 钩子中,将草稿状态通过 AppStorage 同步写入系统的临时内存中,并在重新加载时强行反序列化恢复。
为了规避因为数据丢失导致的稳定性风险,下表对比了 HarmonyOS 中的几种常见数据管理策略:
| 数据管理机制 | 作用域与生命周期 | 适用场景与硬件响应特性 |
|---|---|---|
| LocalStorage | 页面级(绑定特定页面路由树),随页面销毁而清除 | 用于单个页面内部子组件间共享,折叠时如果页面重构极易丢失 |
| AppStorage | 应用级(全局内存共享),随进程生命周期存在 | 适合折叠屏转换、状态劫持等全局断点监测与瞬时状态暂存 |
| PersistentStorage | 持久化级(写入物理闪存),跨应用启动周期存在 | 适合账号体系、核心配置,因涉及磁盘 I/O 不宜高频同步写入 |
4.2 高可靠草稿输入组件设计
下面是结合了 AppStorage 数据热备份与折叠态恢复机制的高可靠 Markdown 编辑器代码实现:
/**
* 具备折叠屏高稳定状态保障的 Markdown 规划内容草稿箱编辑器
*/
@Component
export struct VisionLetterDraftBox {
// 绑定全局临时备份草稿,如果页面在折叠屏变换时被销毁,内存值依然驻留在 AppStorage 中
@StorageLink('visionLetterDraft') draftContent: string = '';
// 注入屏幕尺寸状态,自适应改变输入框的高度 and 排版间距
@StorageLink('deviceScreenSize') deviceScreenSize: string = 'SMALL';
/**
* 组件挂载完毕后的生命周期钩子
* 用于恢复上一次折叠前临时存储的数据
*/
aboutToAppear() {
// 尝试从 AppStorage 读取上一次备份的草稿内容
let backup = AppStorage.get<string>('visionLetterDraft');
if (backup !== undefined && backup !== '') {
this.draftContent = backup;
console.info("DraftBox", "Successfully restored unsaved draft content from AppStorage.");
}
}
build() {
Column() {
// 标题栏区
Row() {
Text("愿景信输入草稿箱")
.fontSize(this.deviceScreenSize === 'LARGE' ? 18 : 16) // 大屏字号放大,优化视觉比例
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
Blank()
Text("状态:已实时备份")
.fontSize(11)
.fontColor('#95A5A6')
}
.width('100%')
.margin({ bottom: 8 })
// 多行文本编辑器主体
TextArea({ text: this.draftContent, placeholder: '请在这里开始记录您的宏伟蓝图...' })
.width('100%')
.height(this.deviceScreenSize === 'LARGE' ? 320 : 180) // 动态适配折叠屏,充分利用展开后的物理纵深
.placeholderColor('#BDC3C7')
.placeholderFont({ size: 13, style: FontStyle.Italic })
.fontSize(14)
.fontColor('#34495E')
.backgroundColor('#F2F4F4')
.borderRadius(8)
.padding(12)
.onChange((value: string) => {
// 每次用户发生输入变更,将状态更新回当前绑定属性
this.draftContent = value;
// 强制实时备份至 AppStorage 共享池中,防止由于折叠屏生命周期销毁重建造成的稳定性风险!
AppStorage.setOrCreate('visionLetterDraft', value);
})
}
.width('100%')
.padding(12)
.backgroundColor('#FDFEFE')
.borderRadius(12)
.shadow({ radius: 8, color: 'rgba(0,0,0,0.04)', offsetX: 0, offsetY: 4 })
}
}
这一层状态热备份机制,彻底消除了折叠屏折叠瞬间可能引入的丢稿故障,带给折叠屏用户工业级的稳定性保障,防止因为配置重新加载导致的数据不合规重置或数据遗失。
5. 总结与下期预告
通过 MediaQuery 媒体查询断点捕捉、ArkUI Grid 与 List 的动态渲染劫持,以及 AppStorage 状态热备份,“轻规划”完美征服了折叠屏在直屏、宽屏切换时的排版与稳定性极限。
现在,我们打通了一多折叠屏架构。如果用户正在拿着手机操作到一半,想通过 P2P 局域网无缝传给平板电脑,这需要使用文件接续。
在下一篇文章中,我们将踏入接续状态的生命周期细节:跨设备无感文件接续与流传状态的底层生命周期深度监听机制! 敬请期待。
更多推荐




所有评论(0)