📱 鸿蒙 HarmonyOS 全栈专栏 · 第 36 篇 / 共 39 篇(第 5 季·收官季)
上一篇:HarmonyOS 游戏与图形渲染入门 | 下一篇:出海与国际化:资源方案与四道合规关

目录

一、断点系统:一套代码,三种窗口

二、栅格与分栏:两个官方组件就够了

三、键鼠交互:真正的适配工作量在这

三点五、交互模式抽象:触屏与鼠标一份代码

四、窗口管理:自由窗口时代的最小尺寸

四点五、一个真实的分栏页面:列表 + 详情

五、适配检查清单


上一篇讲了图形渲染,这一篇讲第二个投票方向:PC 端适配。鸿蒙生态正在向 PC 延伸,而 PC 端的应用数量远少于手机端 —— 这意味着对开发者来说它是一个流量洼地:同样的应用,PC 端竞争更小。

但要先泼一盆冷水:PC 适配不是「把窗口拉大」。布局适配大概只占三成工作量,剩下七成是键鼠交互 —— 手机上不存在的东西,PC 用户天天用。

📖 本篇导读

全文约 15 分钟,改造成本主要是交互细节。

  1. 断点系统:一套代码应对三种窗口宽度
  2. 栅格与分栏:GridRow 与 Navigation 的正确用法
  3. 键鼠交互:焦点、hover、右键、快捷键
  4. 交互模式抽象:触屏与鼠标一份代码
  5. 窗口管理:自由窗口与最小尺寸
  6. 一个真实的分栏页面:列表 + 详情
  7. 适配检查清单与坑表

一、断点系统:一套代码,三种窗口

图 1:断点系统 —— 窗口宽度决定布局形态

鸿蒙把窗口宽度划成三档:sm(手机)、md(折叠屏展开 / 小平板)、lg(平板 / PC)。注意这是窗口宽度而不是设备类型 —— PC 上你的应用可能是自由窗口,宽度随时被用户拖动。

监听断点

import window from '@ohos.window'

@Entry
@Component
struct Index {
  @State bp: string = 'sm'

  aboutToAppear() {
    const win = window.getLastWindow(this.context)
    win.then(w => {
      // 监听窗口变化,运行时更新断点
      w.on('windowSizeChange', (size) => {
        const vp = size.width / display.getDefaultDisplaySync().densityPixels
        this.bp = vp < 600 ? 'sm' : vp < 840 ? 'md' : 'lg'
      })
    })
  }

  build() {
    if (this.bp === 'lg') {
      this.listDetail()    // 平板 / PC:列表 + 详情
    } else {
      this.singlePage()    // 手机:单列
    }
  }
}
💡 实际项目里把断点放进 AppStorage 全局状态,所有页面共享一份 —— 不要每个页面各自监听一遍,那样改断点阈值时要全工程搜索。

二、栅格与分栏:两个官方组件就够了

GridRow / GridCol 是官方栅格组件,列数随断点自动变化:

栅格随断点变化

GridRow({ columns: { sm: 4, md: 8, lg: 12 } }) {
  GridCol({ span: { sm: 4, md: 4, lg: 3 } }) {
    CardA()
  }
  GridCol({ span: { sm: 4, md: 4, lg: 3 } }) {
    CardB()
  }
  // sm 独占一行,lg 时四个卡片同行
}

页面结构上的适配,用 Navigation:它在手机上是普通的页面跳转,在 lg 下自动变成「列表 + 详情」分栏(mode 参数控制)。这正是第 14 篇推荐 Navigation 替代 router 的另一个理由 —— 它天生为多端形态设计。

Navigation 分栏

Navigation(this.pageMap) {
  Column() { /* 列表页 */ }
}
.mode(NavigationMode.Stack)      // sm/md 押栈;lg 可设 Split 分栏
.navBarWidth(360)

三、键鼠交互:真正的适配工作量在这

图 2:键鼠适配的五件事 —— 布局改完只是开始

五件事逐个说,配最小代码:

① 焦点系统。PC 用户会用 Tab 键导航。可聚焦组件要显式声明,默认顺序按声明顺序:

焦点

Button('搜索')
  .focusable(true)
  .onFocus(() => console.info('got focus'))
  .defaultFocus(true)         // 进入页面默认聚焦搜索框

② hover 态。手机没有悬停;没有 hover 反馈的按钮在 PC 上看起来像死的:

hover 态

@Component
struct HoverButton {
  @State hovered: boolean = false
  build() {
    Button(this.label)
      .backgroundColor(this.hovered ? '#0836B0' : '#0A59F7')
      .onHover((isHovered: boolean) => { this.hovered = isHovered })
  }
}

③ 右键菜单。手机上的长按菜单在 PC 上不可达,右键要单独绑:

右键菜单

Text(this.content)
  .bindContextMenu(this.menuBuilder(), ResponseType.RightClick)

④ 快捷键。Ctrl+S 保存、Esc 关弹窗,用按键事件注册:

快捷键

.onKeyEvent((event: KeyEvent) => {
  if (event.type !== KeyType.Down) return
  if (event.keyCode === KeyCode.KEY_ESCAPE) this.close()
})

⑤ 滚轮与拖拽。长列表确认滚轮可用(Scroll 组件默认支持);拖拽行为用 onDragStart 系列 API 定义,和触摸手势分开调。

三点五、交互模式抽象:触屏与鼠标一份代码

上一篇分栏讲了「数据层一份、展示层两份策略」,交互也用同样的思路:把「用户在用触屏还是鼠标」抽象成一个全局状态,hover、右键这些 PC 专属行为只在鼠标态注册 —— 手机端零成本,PC 端一份配置生效。

按交互模式注册行为

// 全局:应用启动时判断(示例:按输入源类型判断,以官方 API 为准)
AppStorage.setOrCreate('isMouse', inputDevice.supportsMouse())

// 组件:按模式注册行为,而不是写两套组件
@Component
struct SmartItem {
  private isMouse = AppStorage.get('isMouse')
  build() {
    Column() { ... }
            .onHover(this.isMouse
      ? (h: boolean) => this.hovered = h
      : undefined)
      .bindContextMenu(this.isMouse ? this.menu : undefined,
                       ResponseType.RightClick)
      // 长按菜单两种模式都保留
      .bindContextMenu(this.menu, ResponseType.LongPress)
  }
}

这样写的收益在维护期才显现:如果 hover、右键的逻辑散落在几十个组件里各自判断,后续改交互规范就是一次全工程搜索;收口到一个模式状态后,改一处生效全局。(inputDevice 的具体 API 名以官方文档为准,这里表达的是分层思路。)

四、窗口管理:自由窗口时代的最小尺寸

PC 上应用以自由窗口运行,用户可以拖小到很离谱的尺寸。两个必做动作:

窗口约束

// 1) 声明最小尺寸,防止用户拖出不可用布局
// module.json5 的 abilities 里:
"minWindowWidth": 480,
"minWindowHeight": 600

// 2) 竖屏手机布局在窄窗口下依然成立 —— 检查你的 sm 断点就是最小窗口
⚠️ 最小窗口宽度的意义是「保底可用」,不是「免检」。发布前把窗口拖到最小尺寸走一遍核心流程 —— 侧滑菜单、弹窗、键盘遮挡,问题都在这时暴露。

四点五、一个真实的分栏页面:列表 + 详情

把前面的东西拼起来看一个完整形态。分栏页面的结构是「导航容器 + 左列表 + 右详情」,断点切换时同一份数据流复用,只有容器形态变化:

分栏页面骨架

@Entry
@Component
struct MailPage {
  @State bp: string = 'sm'
  @State currentId: string = ''

  @Builder listArea() {
    List() {
      ForEach(this.items, (item: Mail) => {
        ListItem() {
          MailItem({ mail: item })
            .onClick(() => { this.currentId = item.id })
        }
      })
    }.width(360)
  }

  @Builder detailArea() {
    if (this.currentId) {
      MailDetail({ id: this.currentId })
    } else {
      Column() { Text('选择一封邮件').fontColor('#999') }
        .justifyContent(FlexAlign.Center).width('100%').height('100%')
    }
  }

  build() {
    if (this.bp === 'lg') {
      Row() {
        this.listArea()
        Divider().vertical(true).height('100%')
        this.detailArea()
      }.width('100%')
    } else {
      // 手机:点击跳转详情页;分栏下:只是刷新右侧
      this.listArea().width('100%')
    }
  }
}

实现时的关键差别:手机端「打开详情」是路由跳转,分栏端是刷新右侧区域。把「用户点了一条列表项」这个行为抽象成 onSelect(id),两种形态各自决定怎么呈现 —— 数据层一份代码,展示层两份策略,这是分栏适配的通用模式。

五、适配检查清单

项手机平板 / PC
列表形态单列全屏跳转lg 分栏:左列表右详情
悬停反馈不需要所有可点元素有 hover 态
右键长按菜单右键菜单 + 长按共存
键盘软键盘遮挡处理Tab 焦点 + 快捷键
窗口固定方向自由尺寸 + 最小宽高声明
输入软键盘同时支持物理键盘与输入法
坑现象处理
断点写死按设备判断PC 小窗口按手机布局,反而正确;但平板大窗口漏判按窗口宽度判,不按设备判
每个页面各自监听窗口阈值改一处要改十处断点放 AppStorage 全局
只适配了布局「套壳感」:鼠标党没法高效操作按本篇五件事补键鼠
忘记最小窗口用户拖成 100px 宽,布局崩坏minWindowWidth 声明 + 最小尺寸自测
弹窗位置写死居中分栏模式下遮住详情相对宿主组件定位

这一篇你该记住:断点按窗口宽度算不按设备算;结构适配靠 GridRow + Navigation;键鼠五件事(焦点 / hover / 右键 / 快捷键 / 滚轮)才是工作量大头;自由窗口时代必须声明最小尺寸。

下一篇讲第三个方向:出海与国际化 —— 把你的应用带到全球市场,代码之外还有四道关。


如果这个专栏帮你把鸿蒙学明白了,点赞 + 收藏 + 关注 支持一下。
你还有哪个场景没跑通?评论区说清楚你的目标 + 现在怎么问的,我帮你改成能用的提示词。

Logo

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

更多推荐