鸿蒙跨平台开发实战:Stage 模型 + 响应式布局 + 交互适配

一、跨平台开发为什么是刚需
做移动应用开发的人大概都经历过这种场景:手机版做好了,老板说平板也要支持,于是开始做平板版。平板版做好了,又说 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
}
这段代码放在一个工具文件里全局复用。每次窗口大小变化时重新调用这个函数,就能得到当前断点值。600vp 和 1200vp 是业界经验值,具体数值可以根据产品实际情况调整——比如如果产品主要面向大屏平板,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 装饰器。当 bp 或 windowWidth 的值发生变化时,框架会自动触发 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 功能网格
网格组件是响应式布局里复用率最高的组件。通过 Grid 和 GridItem 实现,根据传入的列数动态生成列模板。同样的六项功能数据,手机传 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 把同一套卡片放大撑满屏幕。
更多推荐

所有评论(0)