在这里插入图片描述

一、跨平台开发为什么是刚需

做移动应用开发的人大概都经历过这种场景:手机版做好了,老板说平板也要支持,于是开始做平板版。平板版做好了,又说 PC 上也要能跑,用户不可能每次都掏出手机来处理工作文档。结果同样的功能写了三套代码,每套还要单独测试、单独维护,到最后改一个按钮颜色要改三个地方。

这种重复劳动的根本原因是没有在架构层面解决多端适配问题。不同设备的屏幕尺寸、交互方式、性能特性差异巨大,但应用的核心业务逻辑是一样的。好的跨平台方案应该做到"写一次,处处运行",而不是"处处写,到处维护"。

HarmonyOS 从一开始就考虑了这个问题。ArkUI 的声明式 UI 天然具备响应式布局能力,Stage 模型把窗口管理和业务逻辑分离,配套的窗口 API 可以精确获取每个设备当前的显示空间大小。这三样东西组合起来,构成了一套完整的跨平台开发方案——不需要写三套代码,只需要一套代码适配多种窗口尺寸。

这篇文章用一个实际案例来演示完整流程:从项目结构设计、断点体系建立、响应式布局实现,到 PC 专属交互的添加,最后到窗口监听的处理。每个环节都有完整代码,复制到 DevEco Studio 就能跑。

二、项目结构:按功能模块组织代码

动手之前先规划好项目结构。结构清晰,后期维护成本低;结构混乱,代码越写越乱。

鸿蒙标准工程自带一套目录约定,建议在这个基础上根据自己的业务需求做调整。页面放在 pages 目录,一个页面一个 .ets 文件。页面内部的子组件,如果复用率不高,直接内联在页面文件里用 @Builder 定义;如果复用率高,抽取到 components 目录作为独立文件。数据模型统一放 models 目录,所有 TypeScript 接口集中定义。业务逻辑层单独建 services 目录,API 调用、数据校验、导出导入这类操作都放在这里,不要写在组件内部。

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets          # 应用入口,负责窗口管理和页面加载
├── pages/
│   ├── Index.ets                 # 手机主页面
│   └── TwoColumnLayout.ets        # PC/平板双栏布局页面
├── components/
│   ├── TopBar.ets                # 顶部信息栏
│   ├── NoteCard.ets              # 笔记卡片组件
│   ├── FeatureGrid.ets           # 功能网格组件
│   └── SideNav.ets               # 侧边导航栏
├── models/
│   └── NoteModel.ets             # 数据接口定义
├── services/
│   └── NoteService.ets           # 笔记业务逻辑
└── utils/
    └── BreakpointHelper.ets      # 断点判断工具

这个结构的核心理念是内聚性:相关的代码放在一起,改一个功能不需要满项目找文件。每个模块只对自己的事情负责,模块之间通过接口通信,不直接耦合。

值得特别注意的是,EntryAbility.ets 作为整个应用的入口,职责要尽量单纯。它只负责两件事:初始化应用级别的配置,以及根据当前窗口条件决定加载哪个页面。业务逻辑不要写在这里。

三、断点体系:窗口宽度说了算

跨平台适配的第一步是建立断点体系。断点(Breakpoint)是指窗口宽度的临界值,当窗口宽度跨越某个临界值时,布局随之改变。

为什么用窗口宽度而不是设备类型来判断?因为设备类型会骗人。折叠屏展开时 10 英寸、折叠时 6 英寸,但 deviceInfo.deviceType 在两种状态下都返回 “phone”。横屏手机宽度能达到 800vp,远大于竖屏平板的竖边宽度。真正决定内容能否完整展示的空间大小的是窗口宽度,不是设备标称类型。

ArkUI 的三级断点体系已经足够覆盖主流场景:

// utils/BreakpointHelper.ets

// 断点类型
type Breakpoint = 'sm' | 'md' | 'lg'

/**
 * 根据窗口宽度判断当前断点
 * sm: 手机竖屏,单栏布局
 * md: 手机横屏或小平板,中等密度网格
 * lg: PC 或大屏设备,侧边栏 + 多列内容
 */
function getBreakpoint(width: number): Breakpoint {
  if (width < 600) return 'sm'   // 手机
  if (width < 1200) return 'md' // 平板
  return 'lg'                   // PC
}

这段代码放在一个工具文件里全局复用。每次窗口大小变化时重新调用这个函数,就能得到当前断点值。600vp1200vp 是业界经验值,具体数值可以根据产品实际情况调整——比如如果产品主要面向大屏平板,sm 的上限可以放宽到 768vp。

四、Stage 模型:窗口管理与业务逻辑分离

Stage 模型是 HarmonyOS 3.1 引入的新应用框架,相比早期的 FA 模型,最大的改进是把应用分为两个相互独立的层:Window 层负责所有和窗口相关的事情,包括窗口大小、位置、最大化、最小化、多窗口协调;Ability 层只负责业务逻辑和界面渲染,不管窗口怎么变化。

FA 模型的问题在于,窗口管理和业务逻辑混在一起。onWindowCreate 回调里要同时处理页面加载、数据初始化、窗口配置,代码越写越长。一个应用要同时支持手机和平板,只能在 Ability 内部写大量条件判断,导致同一个文件里塞满了和各种设备适配相关的逻辑。

Stage 模型解决了这个问题。Window 相关的操作全部由 WindowStage 对象管理,Ability 只需要关注"应该加载哪个页面"这个决策,剩下的窗口管理由系统框架处理。

EntryAbility.ets 的标准写法如下:

// entry/src/main/ets/entryability/EntryAbility.ets

import UIAbility from '@ohos.app.ability.UIAbility'
import Window from '@ohos.window'

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: Window.WindowStage) {
    // 获取主窗口对象
    const win = windowStage.getMainWindowSync()

    // 将窗口宽度从像素转换为 vp(视觉密度无关像素)
    const width = px2vp(win.getWindowProperties().windowWidth)

    // 根据宽度判断加载哪个页面
    // 768vp 是经验阈值,基本能区分手机(竖屏最大约 430vp)和平板横屏(通常 768vp+)
    const pagePath = width >= 768 ? 'pages/TwoColumnLayout' : 'pages/Index'

    // 加载页面内容
    windowStage.loadContent(pagePath, (err) => {
      if (err.code) {
        console.error('Failed to load page:', JSON.stringify(err))
      }
    })
  }
}

这里有一个细节值得展开。loadContent 的回调只告诉你成功还是失败,如果失败了能做的有限。所以更稳妥的做法是在 loadContent 调用之前就做判断,确保传入的 pagePath 是存在的。如果页面文件不存在,ArkUI 不会报错,只会显示一个空白页面,调试起来非常痛苦。

另外,EntryAbility 里不建议放太多初始化逻辑。应用级别的配置(比如推送权限、埋点初始化、数据库连接池)应该放到专门的初始化模块里,在 onWindowStageCreate 中只调用一个 init() 函数。这样 EntryAbility 保持干净,职责边界清晰。

五、响应式主布局:三个断点三套方案

页面内部的响应式布局是跨平台开发的核心。思路很清晰:@State 保存当前断点,在 aboutToAppear 中获取窗口宽度并计算断点,build() 方法根据断点条件渲染不同的布局结构

ArkUI 的条件渲染用 if / else 语法,不像 CSS 那样用媒体查询。这带来一个重要区别:ArkUI 的条件分支是在编译期就确定的,不是运行时动态切换样式——哪个分支的条件为真就渲染哪个分支的组件树。这意味着可以在不同断点下渲染完全不同的组件结构,而不是同一套结构套不同的样式。

先看整体结构:

// entry/src/main/ets/pages/ResponsiveLayout.ets

import deviceInfo from '@ohos.deviceInfo'

@Entry
@Component
struct ResponsiveLayout {
  // 当前断点,初始值手机
  @State bp: Breakpoint = 'sm'
  // 当前窗口宽度(vp)
  @State windowWidth: number = 360

  aboutToAppear() {
    // 获取主窗口
    const win = Window.getLastWindowSync(getContext(this))

    // 获取窗口宽度并计算断点
    const width = px2vp(win.getWindowProperties().windowWidth)
    this.windowWidth = width
    this.bp = getBreakpoint(width)
  }

  build() {
    Column() {
      // 1. 顶部信息栏,所有断点都显示
      this.TopBar()

      // 2. 根据断点选择布局
      if (this.bp === 'sm') {
        this.MobileLayout()
      } else {
        this.DesktopLayout()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

在这里插入图片描述

5.1 顶部信息栏

顶部栏的目的是让开发者实时看到当前的断点状态,方便调试。生产环境下可以删掉这行,只保留应用标题。

@Builder
TopBar() {
  Row({ space: 10 }) {
    Text('📱').fontSize(18)

    Text(`设备: ${deviceInfo.deviceType}  |  断点: ${this.bp}  |  宽度: ${Math.round(this.windowWidth)}vp`)
      .fontSize(13)
      .fontColor('#666666')

    Blank()

    // 断点标签,视觉上直观区分
    Text(['📱 手机', '📱 平板', '💻 PC'][this.bp === 'sm' ? 0 : this.bp === 'md' ? 1 : 2])
      .fontSize(12)
      .fontColor('#007DFF')
      .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      .backgroundColor('rgba(0,125,255,0.1)')
      .borderRadius(12)
  }
  .width('100%')
  .padding(14)
  .backgroundColor('#FFFFFF')
}

在这里插入图片描述

5.2 手机单栏布局

手机场景的核心约束是单栏。用户单手操作,拇指活动范围有限,屏幕宽度窄,内容只能从上往下堆叠。每一张卡片都是全宽,不需要考虑一行放几张的问题。

交互设计上也有讲究。手机没有 Hover 效果,只有触摸反馈,所以卡片要确保触控热区足够大。底部如果有操作按钮,不要放在内容区的最下方——键盘弹起时底部按钮会被顶出屏幕,应该用 Stack 把关键操作固定在屏幕底部。

@Builder
MobileLayout() {
  Column({ space: 12 }) {
    // 首页卡片
    this.Card('🏠 首页', '单栏垂直堆叠,触摸友好,适合手机竖屏操作。内容区全宽展示,不受屏幕宽度限制。', '#667eea')

    // 笔记卡片
    this.Card('📝 笔记', '点击卡片展开详情,全屏展示编辑区域。底部操作栏固定,键盘弹起时自动避让。', '#4facfe')

    // 购物车卡片
    this.Card('🛒 购物车', '加减数量实时更新合计金额。删除按钮放在卡片右上角,点击后出现确认动画。', '#43E97B')

    // 功能网格,两列
    this.FeatureGrid(2)
  }
  .width('100%')
  .padding(16)
}

在这里插入图片描述

5.3 PC/平板双栏布局

PC 和平板的场景完全不同。屏幕宽度充足,手指变成了鼠标,移动端的一维导航不够用了,需要引入二维导航——左侧固定宽度放导航菜单,右侧放主内容区。

左侧导航栏通常 160–200vp 宽,放应用的主要功能入口。选中态用背景色区分,文字颜色跟随选中状态变化。右侧主内容区用 layoutWeight(1) 占满剩余空间,里面可以放多列内容。

@Builder
DesktopLayout() {
  Row({ space: 0 }) {
    // 左侧导航栏
    this.SideNav()

    // 分隔线
    Divider()
      .vertical(true)
      .height('100%')

    // 右侧主内容区
    Column({ space: 16 }) {
      // 统计卡片行
      Row({ space: 12 }) {
        this.StatCard('总笔记', '1,284', '#667eea')
        this.StatCard('本周新增', '42', '#43E97B')
        this.StatCard('协作人数', '8', '#4facfe')
      }
      .width('100%')

      // 内容卡片
      this.Card('📊 数据概览', '双栏布局,充分利用大屏空间。侧边栏固定宽度,主内容区自适应剩余空间。', '#f093fb')

      // 功能网格:平板三列,PC 四列
      this.FeatureGrid(this.bp === 'md' ? 3 : 4)
    }
    .layoutWeight(1)
    .height('100%')
    .padding(20)
  }
  .width('100%')
}

5.4 响应式布局的底层原理

ArkUI 的响应式布局之所以能做到条件渲染,靠的是 @State 装饰器。当 bpwindowWidth 的值发生变化时,框架会自动触发 build() 方法重新执行,生成新的组件树并对比差异,只更新真正变化的部分。这个过程叫声明式差量更新

和 Flutter 的 Hot Reload 不同,ArkUI 的差量更新是静默的,开发者感知不到过程,只看到最终结果。这意味着即使窗口频繁 resize,也不会出现明显的闪烁或卡顿——前提是 aboutToAppear 中的计算足够轻量,不要在那里做耗时操作。

六、可复用组件

响应式布局里用到几个核心组件,每个都是 @Builder 方法,可以在不同布局中自由组合。

6.1 卡片组件

卡片是最基础的展示单元。设计上的一致性原则是:所有卡片使用相同的背景色、圆角值、阴影参数,只在顶部装饰线的颜色和内容上做区分。

@Builder
Card(title: string, content: string, color: string) {
  Column({ space: 8 }) {
    Text(title)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
    Text(content)
      .fontSize(13)
      .fontColor('#666666')
      .lineHeight(20)
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .shadow({
    radius: 4,
    color: 'rgba(0,0,0,0.06)',
    offsetX: 0,
    offsetY: 2
  })
  // 顶部彩色装饰线
  .border({ top: 3, color: color })
}

6.2 统计卡片

统计卡片的结构和普通卡片一致,区别在于把数字放大、标题缩小,放在垂直居中的位置。适合展示关键指标,比如总用户数、本周新增、转化率。

@Builder
StatCard(label: string, value: string, color: string) {
  Column({ space: 6 }) {
    Text(label)
      .fontSize(13)
      .fontColor('#888888')
    Text(value)
      .fontSize(26)
      .fontWeight(FontWeight.Bold)
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .shadow({
    radius: 4,
    color: 'rgba(0,0,0,0.06)',
    offsetX: 0,
    offsetY: 2
  })
  .border({ top: 3, color: color })
}

6.3 功能网格

网格组件是响应式布局里复用率最高的组件。通过 GridGridItem 实现,根据传入的列数动态生成列模板。同样的六项功能数据,手机传 2 列、平板传 3 列、PC 传 4 列,密度自动适配。

@Builder
FeatureGrid(cols: number) {
  const features = [
    { icon: '📱', name: 'ArkUI' },
    { icon: '🔧', name: 'ArkTS' },
    { icon: '🔗', name: '分布式' },
    { icon: '🚀', name: 'TaskPool' },
    { icon: '💾', name: '存储' },
    { icon: '🌐', name: '网络' },
  ]

  Column({ space: 8 }) {
    Text('功能模块')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .margin({ left: 4 })

    Grid() {
      ForEach(features, (f: { icon: string, name: string }) => {
        GridItem() {
          Column({ space: 6 }) {
            Text(f.icon).fontSize(24)
            Text(f.name).fontSize(13)
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#FAFAFA')
          .borderRadius(10)
          .onClick(() => console.info(`功能: ${f.name}`))
        }
      })
    }
    // 动态列模板:重复 "1fr" cols 次
    .columnsTemplate(`1fr `.repeat(cols).trim())
    .rowsTemplate('1fr 1fr')
    .width('100%')
    .height(160)
    .columnsGap(8)
    .rowsGap(8)
  }
  .width('100%')
  .padding(16)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
}

6.4 侧边导航栏

侧边导航只在 PC 和平板布局中使用。通过 ForEach 渲染导航项,每一项由图标、标签文字和右侧选中指示条组成。

@Builder
SideNav() {
  Column({ space: 4 }) {
    ForEach([
      { icon: '🏠', label: '首页', active: true },
      { icon: '📝', label: '笔记', active: false },
      { icon: '🛒', label: '购物', active: false },
      { icon: '⚙️', label: '设置', active: false },
    ], (item: { icon: string, label: string, active: boolean }) => {
      Row({ space: 10 }) {
        Text(item.icon).fontSize(18)
        Text(item.label)
          .fontSize(14)
          .fontColor(item.active ? '#007DFF' : '#666666')

        // 选中态右侧指示条
        if (item.active) {
          Blank()
          Text('')
            .width(3)
            .height(20)
            .backgroundColor('#007DFF')
            .borderRadius(2)
        }
      }
      .width('100%')
      .height(48)
      .padding({ left: 16, right: 16 })
      .borderRadius(8)
      .backgroundColor(item.active ? 'rgba(0,125,255,0.08)' : 'transparent')
      .onClick(() => console.info(`导航: ${item.label}`))
    })
  }
  .width(160)
  .height('100%')
  .padding(12)
  .backgroundColor('#FFFFFF')
}

七、PC 专属交互

移动端的交互模型是"触摸",PC 端的交互模型是"鼠标 + 键盘"。鼠标带来了悬停状态、右键菜单、双击选择;键盘带来了快捷键、光标导航、Tab 切换。应用如果只适配移动端,这些能力完全用不上。迁移到 PC 时需要针对性地补充。

7.1 悬停效果

onHover 回调是 PC 交互里最常用的。当鼠标指针进入组件区域时回调传入 true,离开时传入 false。利用这个状态变量,可以改变组件的背景色、阴影强度、文字内容,实现比手机触摸更早一步的视觉反馈。

@State hoverText: string = '将鼠标移入蓝色区域'

@Builder
HoverDemo() {
  Column() {
    Text('🖱 Hover 悬停效果').fontSize(14).fontColor('#888888')
    Column()
      .width(240)
      .height(80)
      .backgroundColor('#667eea')
      .borderRadius(12)
      .onHover((isHover: boolean) => {
        // 配合 animateTo 做平滑过渡
        animateTo({ duration: 200 }, () => {
          this.hoverText = isHover
            ? '✅ 鼠标已悬停,可以点击'
            : '将鼠标移入蓝色区域'
        })
      })
      .child(
        Text(this.hoverText)
          .fontSize(13)
          .fontColor('#FFFFFF')
          .width('100%')
          .textAlign(TextAlign.Center)
      )
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
  }
}

7.2 右键菜单

ArkUI 没有内置的右键菜单组件,需要自己实现浮层。思路是:监听右键事件 onContextMenu,阻止默认行为,记录点击坐标,然后渲染一个自定义浮层组件。

@State contextMenuVisible: boolean = false
@State contextMenuX: number = 0
@State contextMenuY: number = 0

@Builder
RightClickDemo() {
  Column({ space: 12 }) {
    Text('右键点击蓝色区域').fontSize(14).fontColor('#888888')

    Column()
      .width(240)
      .height(80)
      .backgroundColor('#4facfe')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      .onContextMenu((event: GestureEvent) => {
        // 获取右键点击的相对坐标
        this.contextMenuX = event.globalX
        this.contextMenuY = event.globalY
        this.contextMenuVisible = true
      })
      .child(
        Text(`右键点击次数: ${this.rightClickCount}`)
          .fontSize(13)
          .fontColor('#FFFFFF')
      )

    // 右键菜单浮层
    if (this.contextMenuVisible) {
      Column({ space: 2 }) {
        ForEach(
          ['📋 复制', '📝 粘贴', '✂️ 剪切', '🗑 删除'],
          (item: string) => {
            Text(item)
              .fontSize(13)
              .fontColor('#333333')
              .width('100%')
              .padding({ left: 12, top: 8, bottom: 8 })
              .onClick(() => {
                this.contextMenuVisible = false
                promptAction.showToast({
                  message: `执行: ${item}`,
                  duration: 1500
                })
              })
          }
        )
      }
      .width(160)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .shadow({
        radius: 8,
        color: 'rgba(0,0,0,0.15)',
        offsetX: 2,
        offsetY: 2
      })
      .position({ x: this.contextMenuX, y: this.contextMenuY })
    }
  }
}

菜单关闭的逻辑也很重要:点击菜单外部、点击菜单项、或者按 Escape 键都应该关闭菜单。实际实现中通常在父容器上加一个透明的 onClick,触发时将 contextMenuVisible 设为 false

7.3 键盘快捷键

PC 应用缺少快捷键就像汽车没有方向盘——功能都有,但用起来别扭。标准快捷键不是随意定义的,而是遵循用户长期养成的习惯。Ctrl+S 保存、Ctrl+N 新建、Ctrl+F 搜索、Escape 关闭——这些都是 Windows 和 macOS 平台上的通用约定,用户看到会有肌肉记忆。

ArkUI 的键盘事件通过 onKeyEvent 捕获,需要在组件上设置 focusable(true) 并注册监听:

@State isSearchFocused: boolean = false

@Builder
ShortcutBar() {
  Column({ space: 8 }) {
    Text('⌨️ 键盘快捷键').fontSize(14).fontWeight(FontWeight.Medium).width('100%')

    Column({ space: 4 }) {
      ForEach([
        { keys: 'Ctrl + S', action: '保存', color: '#667eea' },
        { keys: 'Ctrl + N', action: '新建笔记', color: '#43E97B' },
        { keys: 'Ctrl + F', action: '搜索', color: '#4facfe' },
        { keys: 'Ctrl + Z', action: '撤销', color: '#f093fb' },
        { keys: 'Escape', action: '关闭弹窗', color: '#FF4757' },
      ], (item: { keys: string, action: string, color: string }) => {
        Row() {
          Text(item.keys)
            .fontSize(12)
            .fontWeight(FontWeight.Medium)
            .fontColor(item.color)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(item.color + '20')
            .borderRadius(6)
          Text(item.action)
            .fontSize(13)
            .fontColor('#666666')
            .margin({ left: 12 })
        }
        .width('100%')
        .padding({ top: 4, bottom: 4 })
      })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }
  .width('100%')
  .padding({ left: 16, right: 16 })
}

快捷键的实际处理逻辑通常放在一个单独的方法里:

private handleKeyEvent(event: KeyEvent) {
  if (event.type !== KeyType.Down) return

  const ctrl = event.ctrlKey
  const key = event.keyCode

  if (ctrl && key === 49) {           // Ctrl+S
    this.saveCurrentNote()
    promptAction.showToast({ message: '已保存', duration: 1500 })
    return
  }
  if (ctrl && key === 50) {           // Ctrl+N
    this.createNewNote()
    return
  }
  if (ctrl && key === 52) {           // Ctrl+F
    this.isSearchFocused = true
    return
  }
  if (key === 2) {                    // Escape
    this.closeAllDialogs()
    return
  }
}

八、窗口监听:实时响应窗口变化

PC 应用的窗口大小不是固定的,用户会随时拖动窗口边缘调整大小。应用需要实时感知这些变化并更新断点,重建布局。这个能力通过 window.on('windowSizeChange') 实现。

aboutToAppear() {
  const win = Window.getLastWindowSync(getContext(this))

  // 注册窗口尺寸变化监听
  win.on('windowSizeChange', (size: Window.Size) => {
    const width = px2vp(size.width)
    this.windowWidth = width
    this.bp = getBreakpoint(width)
    console.info(`[Window] Resized to ${width}vp, breakpoint: ${this.bp}`)
  })

  // 获取初始窗口尺寸
  const props = win.getWindowProperties()
  this.windowWidth = px2vp(props.windowWidth)
  this.bp = getBreakpoint(this.windowWidth)
}

aboutToDisappear() {
  const win = Window.getLastWindowSync(getContext(this))
  // 取消监听,防止内存泄漏
  // 每次 aboutToAppear 注册一个新监听,
  // 如果不在此处清理,监听器会越叠越多直到内存溢出
  win.off('windowSizeChange')
}

这是一个很多人踩过的坑。aboutToAppear 每次页面显示时都会执行,aboutToDisappear 每次页面销毁时执行。如果在 aboutToAppear 中注册了监听器但没有在 aboutToDisappear 中取消,每次页面重建就会多一个监听器。窗口 resize 事件会同时触发所有已注册的监听,导致同一个回调被执行多次,甚至出现数据不一致的问题。更严重的是,这些"死监听器"会阻止相关对象被垃圾回收,最终导致内存占用持续增长。

解决方法是始终配对注册和取消,不要抱侥幸心理。

九、完整代码整合

把上面的所有模块整合起来,得到一个完整可运行的响应式布局页面:

// entry/src/main/ets/pages/ResponsiveLayout.ets

import deviceInfo from '@ohos.deviceInfo'
import Window from '@ohos.window'

type Breakpoint = 'sm' | 'md' | 'lg'

function getBreakpoint(width: number): Breakpoint {
  if (width < 600) return 'sm'
  if (width < 1200) return 'md'
  return 'lg'
}

@Entry
@Component
struct ResponsiveLayout {
  @State bp: Breakpoint = 'sm'
  @State windowWidth: number = 360
  @State hoverText: string = '将鼠标移入蓝色区域'
  @State contextMenuVisible: boolean = false
  @State contextMenuX: number = 0
  @State contextMenuY: number = 0
  @State rightClickCount: number = 0

  aboutToAppear() {
    const win = Window.getLastWindowSync(getContext(this))

    win.on('windowSizeChange', (size: Window.Size) => {
      this.windowWidth = px2vp(size.width)
      this.bp = getBreakpoint(this.windowWidth)
    })

    const props = win.getWindowProperties()
    this.windowWidth = px2vp(props.windowWidth)
    this.bp = getBreakpoint(this.windowWidth)
  }

  aboutToDisappear() {
    const win = Window.getLastWindowSync(getContext(this))
    win.off('windowSizeChange')
  }

  build() {
    Column() {
      // 顶部栏
      Row({ space: 10 }) {
        Text('📱').fontSize(18)
        Text(`设备: ${deviceInfo.deviceType}  |  断点: ${this.bp}  |  宽度: ${Math.round(this.windowWidth)}vp`)
          .fontSize(13).fontColor('#666666')
        Blank()
        Text(['📱 手机', '📱 平板', '💻 PC'][this.bp === 'sm' ? 0 : this.bp === 'md' ? 1 : 2])
          .fontSize(12).fontColor('#007DFF')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('rgba(0,125,255,0.1)').borderRadius(12)
      }
      .width('100%').padding(14).backgroundColor('#FFFFFF')

      // 主内容区
      if (this.bp === 'sm') {
        this.MobileLayout()
      } else {
        this.DesktopLayout()
      }
    }
    .width('100%').height('100%').backgroundColor('#F5F5F5')
  }

  @Builder MobileLayout() {
    Column({ space: 12 }) {
      this.Card('🏠 首页', '单栏垂直堆叠,触摸友好,适合手机竖屏操作。', '#667eea')
      this.Card('📝 笔记', '点击卡片展开详情,全屏编辑。', '#4facfe')
      this.Card('🛒 购物车', '加减数量,实时合计。', '#43E97B')
      this.FeatureGrid(2)
    }.width('100%').padding(16)
  }

  @Builder DesktopLayout() {
    Row({ space: 0 }) {
      // 侧边导航
      Column({ space: 4 }) {
        ForEach([
          { icon: '🏠', label: '首页', active: true },
          { icon: '📝', label: '笔记', active: false },
          { icon: '🛒', label: '购物', active: false },
          { icon: '⚙️', label: '设置', active: false },
        ], (item: { icon: string, label: string, active: boolean }) => {
          Row({ space: 10 }) {
            Text(item.icon).fontSize(18)
            Text(item.label).fontSize(14).fontColor(item.active ? '#007DFF' : '#666666')
            if (item.active) { Blank(); Text('').width(3).height(20).backgroundColor('#007DFF').borderRadius(2) }
          }
          .width('100%').height(48).padding({ left: 16, right: 16 }).borderRadius(8)
          .backgroundColor(item.active ? 'rgba(0,125,255,0.08)' : 'transparent')
        })
      }.width(160).padding(12).backgroundColor('#FFFFFF')

      Divider().vertical(true).height('100%')

      // 主内容
      Column({ space: 16 }) {
        Row({ space: 12 }) {
          this.StatCard('总笔记', '1,284', '#667eea')
          this.StatCard('本周新增', '42', '#43E97B')
          this.StatCard('协作人数', '8', '#4facfe')
        }.width('100%')

        this.Card('📊 数据概览', '双栏布局,充分利用大屏空间。', '#f093fb')
        this.FeatureGrid(this.bp === 'md' ? 3 : 4)
      }.layoutWeight(1).padding(20)
    }.width('100%')
  }

  @Builder Card(title: string, content: string, color: string) {
    Column({ space: 8 }) {
      Text(title).fontSize(16).fontWeight(FontWeight.Bold)
      Text(content).fontSize(13).fontColor('#666666').lineHeight(20)
    }
    .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .border({ top: 3, color: color })
  }

  @Builder StatCard(label: string, value: string, color: string) {
    Column({ space: 6 }) {
      Text(label).fontSize(13).fontColor('#888888')
      Text(value).fontSize(26).fontWeight(FontWeight.Bold)
    }
    .width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
    .shadow({ radius: 4, color: 'rgba(0,0,0,0.06)', offsetX: 0, offsetY: 2 })
    .border({ top: 3, color: color })
  }

  @Builder FeatureGrid(cols: number) {
    const features = [
      { icon: '📱', name: 'ArkUI' }, { icon: '🔧', name: 'ArkTS' },
      { icon: '🔗', name: '分布式' }, { icon: '🚀', name: 'TaskPool' },
      { icon: '💾', name: '存储' }, { icon: '🌐', name: '网络' },
    ]
    Column({ space: 8 }) {
      Text('功能模块').fontSize(14).fontWeight(FontWeight.Bold).width('100%').margin({ left: 4 })
      Grid() {
        ForEach(features, (f: { icon: string, name: string }) => {
          GridItem() {
            Column({ space: 6 }) {
              Text(f.icon).fontSize(24)
              Text(f.name).fontSize(13)
            }.width('100%').height('100%').justifyContent(FlexAlign.Center)
            .backgroundColor('#FAFAFA').borderRadius(10)
            .onClick(() => console.info(`功能: ${f.name}`))
          }
        })
      }
      .columnsTemplate(`1fr `.repeat(cols).trim())
      .rowsTemplate('1fr 1fr')
      .width('100%').height(160).columnsGap(8).rowsGap(8)
    }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(12)
  }
}

十、实战建议

第一条原则:断点判断基于窗口宽度,不基于设备类型。 任何试图通过 deviceInfo.deviceType 来决定布局的做法最终都会遇到折叠屏、旋转屏、超级平板这类边界场景。窗口宽度是用户实际能看到的空间大小,这个值永远不会骗人。

第二条原则:窗口监听必须配对注册和取消。 这一条在前面已经反复强调过了。内存泄漏在开发阶段很难发现——应用跑几分钟看不出问题,跑几个小时才开始卡顿,等到用户反馈时已经很难定位了。从一开始就养成习惯,每次注册监听必然对应一次取消监听。

第三条原则:PC 特有交互用条件分支增强,不要无条件添加。 Hover、快捷键、右键菜单都是 PC 独有的能力,在手机上没有意义。对于这些能力,只在检测到 deviceInfo.deviceType 为特定值或窗口宽度达到一定规模时才启用,避免在移动端浪费性能。

第四条原则:布局适配在 TypeScript 逻辑里,不在 CSS 样式里。 ArkUI 的条件渲染和 CSS 媒体查询的思路完全不同。CSS 媒体查询是"同一套结构换样式",ArkUI 是"不同条件渲染不同结构"。用 CSS 思维写 ArkUI 布局会走很多弯路。

第五条原则:PC 适配不是把手机布局放大,而是重新设计信息密度。 手机屏幕空间有限,必须对内容做取舍;PC 屏幕空间充裕,可以同时展示更多维度。用户在不同设备上的任务不同,信息架构也应该相应调整——而不是简单地用 @media 把同一套卡片放大撑满屏幕。


Logo

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

更多推荐