轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适配

背景介绍

在之前的开发中,我们为“轻规划”(AeroPlan)自研了一套高强度的 Markdown AST(抽象语法树)解析渲染器,用以把 AI 导师智能输出的 Markdown 文本优雅地格式化为“曼陀罗九宫格”全景大图。
轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适配-2.png
但在实际体验中,折叠屏(Foldable Device)为我们提出了更严苛的屏幕一多适配挑战。

折叠屏拥有典型的三种屏幕姿态

  1. 折叠态(直屏,小宽度):用户单手持握,屏幕较窄,九宫格强行排布 3 列会导致文字严重挤压甚至重叠。
  2. 展开态(大屏,正方形):九宫格完全舒展,雷达图并列排版最合适。
  3. 半折叠态(悬停态):上部屏幕显示愿景信,下半部分键盘或操作区。

如果我们的 Markdown AST 渲染器只会死板地拉伸,在屏幕宽度骤变时,界面布局就会变形崩溃。

今天,我们将聚焦 Markdown 渲染层,利用 ArkUI 的 一多动态媒体查询(Media Query)栅格断点(Breakpoints),实战解构折叠屏状态下的自适应渲染劫持方案。


1. 架构纵览:折叠屏姿态感知与 UI 自适应重构管线

在折叠屏展开收起时,系统底层的窗口大小会发生物理跳变。我们通过媒体查询组件注册视口监听,无缝改变渲染网格列数与图表排列。职责划分如下:

轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适配.png

1.1 核心设计考量

为了在折叠屏设备上提供极致的用户体验,系统必须具备在毫秒级内感知屏幕形态变化并作出响应的能力。本方案的设计核心是将“物理屏幕监测”与“组件渲染劫持”完全解耦:

  • 感知层(Sensor Layer):由 ScreenOrientationTracker 负责监听底层的屏幕尺寸广播,消除冗余计算,将宽度断点转化为清晰的状态指令(SMALLLARGE)。
  • 状态总线(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 })
  }
}

这一层状态热备份机制,彻底消除了折叠屏折叠瞬间可能引入的丢稿故障,带给折叠屏用户工业级的稳定性保障,防止因为配置重新加载导致的数据不合规重置或数据遗失。

轻规划鸿蒙开发实战18:富文本与 Markdown AST 组件的动态渲染劫持及折叠屏一多适配-1.png

5. 总结与下期预告

通过 MediaQuery 媒体查询断点捕捉、ArkUI Grid 与 List 的动态渲染劫持,以及 AppStorage 状态热备份,“轻规划”完美征服了折叠屏在直屏、宽屏切换时的排版与稳定性极限。

现在,我们打通了一多折叠屏架构。如果用户正在拿着手机操作到一半,想通过 P2P 局域网无缝传给平板电脑,这需要使用文件接续。

在下一篇文章中,我们将踏入接续状态的生命周期细节:跨设备无感文件接续与流传状态的底层生命周期深度监听机制! 敬请期待。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐