登录页适配的正确姿势:手机占满,大屏收窄

发布时间:2026/6/26 16:15:29
登录页适配的正确姿势:手机占满,大屏收窄 跨端应用开发中登录页适配是个看似简单、实则容易翻车的活。设计师给了一套手机尺寸的设计稿——卡片居中、输入框舒适、按钮大小刚好。你照着实现在手机上跑起来确实漂亮。然后你把应用拖到平板模拟器上——登录卡片像被吹了气一样膨胀输入框横跨整个屏幕按钮宽得像一堵墙。产品经理走过来说“让它在大屏上也显得合适一点。”“合适”——这个词意味着不是平铺拉满也不是固定死板而是根据屏幕大小自动调整到舒适的视觉状态。本文分享一个具体的实现方案手机屏幕让卡片撑满但保留呼吸感平板及以上屏幕让卡片收窄到 50% 并居中。方案基于 ArkUI 的栅格系统 媒体查询工具类代码完整可运行。一、先说结论三个数字定义“合适”屏幕类型断点卡片占列偏移卡片实际占比内边距手机xs/smxs/sm12列0列接近 100%左右各 30vp平板/桌面mdmd6列3列约 50%0为什么是 6 列ArkUI 栅格系统把屏幕横向切成 12 列。6 列 50% 宽度加上左右各偏移 3 列正好居中。50% 是人眼阅读最舒适的区间——不会显得空旷也不会显得拥挤。为什么手机上要占满手机屏幕本身就小不需要“收窄”。但完全贴边显得挤所以左右各留 30vp 内边距在“占满”和“呼吸”之间找平衡。二、工具类MediaQueryUtil核心代码在开始写登录页之前先准备好媒体查询工具类。这个类只做一件事监听屏幕宽度变化计算出对应的栅格断点。// utils/MediaQueryUtil.etsimport{mediaquery}fromkit.ArkUI;/** 栅格断点类型 */exporttypeGridBreakpointTypexs|sm|md|lg|xl|xxl;/** 监听项内部管理接口 */interfaceMediaQueryListenerItem{listener:mediaquery.MediaQueryListener;callback:(result:mediaquery.MediaQueryResult)void;}exportclassMediaQueryUtil{privatestaticinstance:MediaQueryUtil|nullnull;privateuiContext?:UIContext;privatelistenerMap:Mapstring,MediaQueryListenerItemnewMap();privatebreakpoints:number[][320,600,840,1440,1600];privateconstructor(){}staticgetInstance():MediaQueryUtil{if(!MediaQueryUtil.instance){MediaQueryUtil.instancenewMediaQueryUtil();}returnMediaQueryUtil.instance;}/** * 初始化工具类 * param uiContext 应用上下文 * param customBreakpoints 自定义栅格断点单位vp */init(uiContext:UIContext,customBreakpoints?:number[]):void{if(!this.uiContext){this.uiContextuiContext;}if(customBreakpoints?.length){this.breakpoints[...customBreakpoints];}}/** * 注册监听 */privateregister(key:string,condition:string,onChange:(isMatch:boolean)void):void{if(!this.uiContext){thrownewError(MediaQueryUtil 未初始化请先调用 init(uiContext));}this.removeListener(key);constlistenerthis.uiContext.getMediaQuery().matchMediaSync(condition);constcallback(result:mediaquery.MediaQueryResult){onChange(!!result.matches);};listener.on(change,callback);this.listenerMap.set(key,{listener,callback});onChange(!!listener.matches);}/** * 移除单个监听 */privateremoveListener(key:string):void{constitemthis.listenerMap.get(key);if(item){item.listener.off(change,item.callback);this.listenerMap.delete(key);}}/** * 移除所有监听 */removeAllListeners():void{this.listenerMap.forEach(item{item.listener.off(change,item.callback);});this.listenerMap.clear();}/** * 销毁工具类 */destroy():void{this.removeAllListeners();this.uiContextundefined;MediaQueryUtil.instancenull;}/** * 监听栅格断点变化 * param onChange 断点变化回调 */onBreakpointChange(onChange:(breakpoint:GridBreakpointType)void):void{constbpsthis.breakpoints;constupdate(){constmqthis.uiContext!.getMediaQuery();letbp:GridBreakpointTypexs;if(mq.matchMediaSync((width ${bps[4]}vp)).matches)bpxxl;elseif(mq.matchMediaSync((width ${bps[3]}vp)).matches)bpxl;elseif(mq.matchMediaSync((width ${bps[2]}vp)).matches)bplg;elseif(mq.matchMediaSync((width ${bps[1]}vp)).matches)bpmd;elseif(mq.matchMediaSync((width ${bps[0]}vp)).matches)bpsm;onChange(bp);};this.register(internal_bp,(width 0vp),update);}}这个工具类的设计思路单例模式全局共享避免重复创建监听断点阈值可自定义默认[320, 600, 840, 1440, 1600]对应 xs/sm/md/lg/xl/xxl资源安全页面销毁时调用removeAllListeners避免内存泄漏三、登录页完整代码有了工具类登录页只需要做三件事初始化、注册监听、清理监听。// pages/LoginPage.etsimport{MediaQueryUtil,GridBreakpointType}from../utils/MediaQueryUtil;EntryComponentstruct LoginPage{// 断点状态仅调试用Statebreakpoint:GridBreakpointTypesm;// 栅格配置 —— 由断点驱动StatespanConfig:GridColColumnOption{xs:12,sm:12,md:6,lg:6,xl:6,xxl:6};StateoffsetConfig:GridColColumnOption{xs:0,sm:0,md:3,lg:3,xl:3,xxl:3};StatecardHorizontalPadding:number30;// 表单数据Stateusername:string;Statepassword:string;aboutToAppear():void{constutilMediaQueryUtil.getInstance();util.init(this.getUIContext());// 一行代码注册断点监听util.onBreakpointChange((bp:GridBreakpointType){this.breakpointbp;this.updateConfig(bp);});}aboutToDisappear():void{// 清理所有监听MediaQueryUtil.getInstance().removeAllListeners();}privateupdateConfig(bp:GridBreakpointType):void{constisMobilebpxs||bpsm;constspanisMobile?12:6;constoffsetisMobile?0:3;// 所有断点统一赋值避免继承链带来的闪烁this.spanConfig{xs:span,sm:span,md:span,lg:span,xl:span,xxl:span};this.offsetConfig{xs:offset,sm:offset,md:offset,lg:offset,xl:offset,xxl:offset};this.cardHorizontalPaddingisMobile?30:0;}BuilderLoginCard(){Column({space:25}){// 调试标签开发阶段显示断点方便验证Row(){Text(${this.breakpoint.toUpperCase()}).fontSize(13).fontColor(#999).padding({left:10,right:10,top:3,bottom:3}).backgroundColor(#F0F0F0).borderRadius(10)}.width(100%).justifyContent(FlexAlign.Center)// 标题Column({space:6}){Text( 欢迎回来).fontSize(28).fontWeight(FontWeight.Bold).fontColor(#1A1A1A)Text(登录您的账号继续探索).fontSize(15).fontColor(#999)}.width(100%).alignItems(HorizontalAlign.Center)// 表单Column({space:12}){TextInput({placeholder:用户名 / 手机号,text:this.username}).width(100%).height(48).backgroundColor(#F5F7FA).borderRadius(10).borderWidth(1.5).borderColor(#E8ECF0).fontSize(16).padding({left:14,right:14}).onChange((v:string){this.usernamev})TextInput({placeholder:密码,text:this.password}).width(100%).height(48).backgroundColor(#F5F7FA).borderRadius(10).borderWidth(1.5).borderColor(#E8ECF0).fontSize(16).padding({left:14,right:14}).type(InputType.Password).onChange((v:string){this.passwordv})}.width(100%).padding(16).backgroundColor(Color.White).borderRadius(14).shadow({radius:16,color:#0000000A,offsetY:4})// 登录按钮Button(登 录).width(100%).height(50).backgroundColor(#007AFF).fontSize(17).fontWeight(FontWeight.Medium).fontColor(Color.White).shadow({radius:12,color:#007AFF30,offsetY:4}).onClick((){console.info(登录尝试${this.username})})// 辅助功能Row({space:20}){Text(注册账号).fontSize(14).fontColor(#007AFF).onClick((){console.info(跳转注册)})Text(|).fontSize(14).fontColor(#E0E0E0)Text(忘记密码).fontSize(14).fontColor(#007AFF).onClick((){console.info(跳转找回密码)})}.width(100%).justifyContent(FlexAlign.Center)}.width(100%).height(100%).padding({left:this.cardHorizontalPadding,right:this.cardHorizontalPadding}).justifyContent(FlexAlign.Center)}build(){GridRow({columns:12,gutter:{x:0,y:0}}){GridCol({span:this.spanConfig,offset:this.offsetConfig}){this.LoginCard()}}.width(100%).height(100%).backgroundColor(#FFFFFF).expandSafeArea([SafeAreaType.SYSTEM],[SafeAreaEdge.TOP,SafeAreaEdge.BOTTOM])}}不同屏幕尺寸上运行效果四、三个关键设计决策4.1 为什么所有断点要逐个赋值ArkUI 的GridColColumnOption支持断点继承——写了md: 6lg、xl会自动继承。但实际运行时窗口在断点边界反复横跳时继承链可能导致 UI 出现一帧“中间态”。手动为所有断点统一赋值换来的是响应确定性。State变量整体替换ArkUI 检测到引用变化一次刷新到位没有闪烁。4.2 为什么用State而不是直接传对象// ❌ 每次 updateConfig 都创建新对象但 UI 不刷新privatespanConfig{xs:12,sm:12,md:6,lg:6,xl:6,xxl:6};// ✅ 用 State变化触发 UI 刷新StatespanConfig:GridColColumnOption{...};State是 ArkUI 响应式系统的入口。只有State变量变化GridCol才会重新渲染。4.3 为什么卡片内边距也要跟着变手机屏幕小卡片撑满后如果完全贴边视觉上很拥挤。大屏卡片只占 50%两侧已经有大量留白卡片内部不需要额外内边距。同一个断点信息驱动多个 UI 属性同步变化——这是“媒体查询驱动”比“静态栅格配置”强大的地方。静态栅格只能控制span/offset而我们的方案可以控制任意属性。五、运行效果屏幕形态断点卡片表现手机竖屏sm占满宽度左右内边距 30vp平板竖屏md占 50%居中无额外内边距平板横屏lg占 50%居中无额外内边距桌面大屏xl占 50%居中无额外内边距验证方式DevEco Studio 中切换不同尺寸模拟器或直接拖拽预览器窗口。六、扩展同一套模式还能用在哪儿这个工具类不只能驱动栅格还能驱动任何 UI 属性。比如// 根据断点调整字体大小util.onBreakpointChange((bp){this.titleFontSizebpxs||bpsm?24:32;});// 自定义断点阈值util.init(this.getUIContext(),[360,640,880,1280,1600]);核心公式不变媒体查询 → 状态变量 → 任意 UI 属性。七、避坑清单问题原因解决方法断点监听不生效工具类未初始化aboutToAppear中先调用init(this.getUIContext())页面销毁后仍有回调监听未清理aboutToDisappear中调用removeAllListeners()断点变了 UI 不变配置对象不是StatespanConfig和offsetConfig加State卡片不居中offset 算错统一用(12 - span) / 2大屏卡片太宽或太窄span 值不合适6 列最舒适不要用 4 列或 8 列八、总结登录页适配的核心不是“把东西填满”而是“把东西放在它该在的位置上”。