鸿蒙跨平台开发:Stage 模型 + 响应式布局

📅 2026/7/10 1:35:50 👁️ 阅读次数 📝 编程学习
鸿蒙跨平台开发:Stage 模型 + 响应式布局

一、为什么需要 Stage 模型

早期鸿蒙用 FA 模型开发,每个页面就是一个 Ability,生命周期和窗口管理混在一起。手机场景下够用,但一旦要适配手表、平板、PC,FA 模型的局限就暴露了:窗口大小变化没有统一处理机制,不同设备的生命周期行为不一致,资源调度也缺乏标准。

Stage 模型的核心思路是把应用分成两层:Window 层负责窗口管理(尺寸、多窗口、最大化),Ability 层负责业务逻辑。两层各司其职,Ability 只关心页面内容该怎么渲染,不管窗口怎么变化,跨平台适配因此变得清晰。Ability 的生命周期只描述业务状态,窗口的状态由 Window 层独立维护。

二、断点体系设计

适配多设备的第一步是建立断点体系。ArkUI 不需要设备类型检测,只需要关注一个数字——窗口宽度。设备类型会骗人,比如折叠屏展开 10 英寸、折叠时 6 英寸,但deviceInfo.deviceType始终返回 “phone”。真正反映用户实际能看到的空间大小的,是窗口宽度。

业界常用的三级断点足够覆盖主流场景:

  • sm(< 600vp):手机竖屏,单栏布局。
  • md(600–1200vp):手机横屏或小平板,中等密度网格。
  • lg(≥ 1200vp):PC 或大屏设备,侧边栏 + 多列内容。

拿到窗口宽度后,调用一个工具函数就能得到当前断点,代码不过十几行:

typeBreakpoint='sm'|'md'|'lg'functiongetBreakpoint(width:number):Breakpoint{if(width<600)return'sm'// 手机if(width<1200)return'md'// 平板return'lg'// PC}

这个函数放在一个工具模块里,全局复用。每次aboutToAppear时重新计算,保证窗口 resize 后布局也能正确更新。

三、Ability 入口:根据窗口宽度选择页面

EntryAbility.ets是应用启动的必经之地,在这里根据窗口宽度决定加载哪个页面。ArkUI 的条件加载能力让这一步变得简单——不需要写多套页面,只需要在入口处做一次路径选择:

importUIAbilityfrom'@ohos.app.ability.UIAbility'importWindowfrom'@ohos.window'exportdefaultclassEntryAbilityextendsUIAbility{onWindowStageCreate(windowStage:Window.WindowStage){constwin=windowStage.getMainWindowSync()constwidth=px2vp(win.getWindowProperties().windowWidth)// 768vp 是经验值,基本能区分手机(竖屏最大约 430vp)和平板横屏constpagePath=width>=768?'pages/TwoColumnLayout':'pages/Index'windowStage.loadContent(pagePath,(err)=>{if(err.code)console.error('loadContent failed:',JSON.stringify(err))})}}

768vp 是一个经验值,具体数值可根据产品实际需求调整。如果应用需要支持折叠屏,建议在折叠切换时重新计算断点并触发页面重建。

四、响应式布局的核心结构

页面内部用@State保存当前断点,在aboutToAppear中获取窗口宽度并计算断点,然后build()方法根据断点调用不同的@Builder渲染内容。ArkUI 的条件渲染语法是if / else分支,而非 CSS 的媒体查询——哪个分支返回就渲染哪个布局,逻辑更直观。

关键在于:布局适配不在 CSS 里,而在 TypeScript 逻辑里。这意味着断点切换时可以配合动画、数据加载等复杂行为,而不只是单纯的样式变化。

@Entry@Componentstruct ResponsiveLayout{@Statebp:Breakpoint='sm'@StatewindowWidth:number=360aboutToAppear(){constwin=Window.getLastWindowSync(getContext(this))this.windowWidth=px2vp(win.getWindowProperties().windowWidth)this.bp=getBreakpoint(this.windowWidth)}build(){Column(){this.TopBar()if(this.bp==='sm')this.MobileLayout()elsethis.DesktopLayout()}.width('100%').height('100%').backgroundColor('#F5F5F5')}// 顶部设备信息栏,所有断点都显示@BuilderTopBar(){Row({space:10}){Text('📱').fontSize(18)Text(`设备:${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')}// 手机:单栏垂直堆叠@BuilderMobileLayout(){Column({space:12}){this.Card('🏠 首页','单栏垂直堆叠,触摸友好,适合手机竖屏。','#667eea')this.Card('📝 笔记','卡片点击展开,全屏显示详情。','#4facfe')this.Card('🛒 购物车','加减数量,实时合计金额。','#43E97B')this.FeatureGrid(2)}.width('100%').padding(16)}// PC/平板:双栏分栏@BuilderDesktopLayout(){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')this.FeatureGrid(this.bp==='md'?3:4)}.layoutWeight(1).padding(20)}.width('100%')}}

手机布局适合单栏垂直堆叠,触摸交互友好的卡片从顶到底排列,操作按钮放在底部固定区域。每张卡片都是全宽,文字不换行不截断,触控热区大,不容易误操作。

平板布局可以放宽约束,在外层加maxWidth: 600限制内容宽度,让内容区居中而不是撑满整个屏幕。网格列数增加到 3 列,信息密度提升但仍保持良好的可读性。

PC 布局Row分成左右两栏,左侧 160vp 固定宽度放导航,右侧layoutWeight(1)占满剩余空间放主内容。主内容区进一步用Row展示统计数据卡片(宽于普通卡片),网格列数 4 列,充分利用大屏空间。

五、可复用组件

三个@Builder组件负责渲染具体内容,可以在任何布局中复用,代码量不大但使用频率最高:

卡片组件最通用,上边加一条彩色装饰线区分不同内容区,内部放标题和描述文字,背景白色加圆角和投影。手机场景下全宽显示,PC 场景下宽度受父容器约束,一套样式自适应。

统计卡片和普通卡片结构一致,区别在于把标题字号加大、数字字号放到 26 加粗,适合展示关键指标。PC 上可以把多个统计卡片排成一行,手机上就垂直堆叠。

网格组件通过Grid+GridItem实现,根据传入的列数动态生成列模板。同样一组功能数据,手机传 2 列、平板传 3 列、PC 传 4 列,同样的数据源在不同断点下自动适配密度,无需写三套代码。

@BuilderCard(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})}@BuilderStatCard(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})}@BuilderFeatureGrid(cols:number){constfeatures=[{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)=>{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)}

六、侧边导航组件

PC 场景下侧边导航栏是标配。和手机底部的 TabBar 不同,侧边导航通常更宽,可以放下图标加文字,还可以加选中态的彩色指示条。导航项的数据结构是数组,用ForEach渲染,点击时更新当前选中状态:

@BuilderSideNav(){Column({space:4}){ForEach([{icon:'🏠',label:'首页',active:true},{icon:'📝',label:'笔记',active:false},{icon:'🛒',label:'购物',active:false},{icon:'⚙️',label:'设置',active:false},],(item)=>{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 上的交互比手机丰富得多。鼠标悬停(Hover)是 PC 独有的——鼠标移入时触发状态变化,可以做颜色加深、阴影增强等视觉反馈,比手机触摸更早一步给用户暗示。ArkUI 通过onHover回调处理,传入布尔值区分进入和离开,配合animateTo可以做平滑的过渡动画:

@StatehoverText:string='将鼠标移入蓝色区域'Column().width(200).height(80).backgroundColor('#667eea').borderRadius(12).onHover((isHover:boolean)=>{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)

键盘快捷键是 PC 效率工具的核心。Ctrl+S 保存、Ctrl+N 新建、Ctrl+F 搜索、Escape 关闭——这些约定俗成的快捷键可以显著提升 PC 用户的操作效率,在表单类应用中尤其重要。ArkUI 目前通过监听键盘事件处理,后续版本会提供更完整的快捷键注册机制。

八、窗口监听与动态适配

PC 应用需要实时响应窗口大小的改变。通过window.on('windowSizeChange')可以监听窗口变化,在回调里重新计算断点并更新状态变量,布局会自动重建:

aboutToAppear(){constwin=Window.getLastWindowSync(getContext(this))// 注册窗口变化监听win.on('windowSizeChange',(size)=>{this.windowWidth=px2vp(size.width)this.bp=getBreakpoint(this.windowWidth)})}aboutToDisappear(){// 取消监听,防止内存泄漏constwin=Window.getLastWindowSync(getContext(this))win.off('windowSizeChange')}

这里有一个常见的坑:监听注册后没有取消。每次页面重建时aboutToAppear都会被调用,如果不在aboutToDisappear中清理,监听器会越积越多,内存占用持续增长,直到应用崩溃。

九、实战建议

适配的核心原则是从窗口宽度出发,而非设备类型windowWidth >= 768这种判断直接反映用户实际能看到的空间大小,而非设备标称形态。设备类型会骗人,但窗口宽度不会。

组件设计应遵循最大公约数原则:优先使用所有平台都支持的交互(点击、长按),PC 特有的 Hover 和右键菜单用条件分支增强,不确定时优先用点击而非手势。断点变化时用animateTo包裹布局属性过渡,避免内容闪烁。

页面路径选择放在 Ability 入口层,组件内部只做布局适配——这个分层原则值得坚持。页面路径是架构决策,布局适配是表现层细节,两者在不同层级处理,代码更清晰,维护成本也更低。