登录页适配的正确姿势:手机占满,大屏收窄
跨端应用开发中,登录页适配是个看似简单、实则容易翻车的活。
设计师给了一套手机尺寸的设计稿——卡片居中、输入框舒适、按钮大小刚好。你照着实现,在手机上跑起来确实漂亮。然后你把应用拖到平板模拟器上——登录卡片像被吹了气一样膨胀,输入框横跨整个屏幕,按钮宽得像一堵墙。
产品经理走过来说:“让它在大屏上也显得合适一点。”
“合适”——这个词意味着不是平铺拉满,也不是固定死板,而是根据屏幕大小自动调整到舒适的视觉状态。
本文分享一个具体的实现方案:手机屏幕让卡片撑满但保留呼吸感,平板及以上屏幕让卡片收窄到 50% 并居中。方案基于 ArkUI 的栅格系统 + 媒体查询工具类,代码完整可运行。
一、先说结论:三个数字定义“合适”
| 屏幕类型 | 断点 | 卡片占列 | 偏移 | 卡片实际占比 | 内边距 |
|---|---|---|---|---|---|
| 手机(xs/sm) | xs/sm | 12列 | 0列 | 接近 100% | 左右各 30vp |
| 平板/桌面(md+) | md+ | 6列 | 3列 | 约 50% | 0 |
为什么是 6 列?
ArkUI 栅格系统把屏幕横向切成 12 列。6 列 = 50% 宽度,加上左右各偏移 3 列,正好居中。50% 是人眼阅读最舒适的区间——不会显得空旷,也不会显得拥挤。
为什么手机上要占满?
手机屏幕本身就小,不需要“收窄”。但完全贴边显得挤,所以左右各留 30vp 内边距,在“占满”和“呼吸”之间找平衡。
二、工具类:MediaQueryUtil(核心代码)
在开始写登录页之前,先准备好媒体查询工具类。这个类只做一件事:监听屏幕宽度变化,计算出对应的栅格断点。
// utils/MediaQueryUtil.ets
import { mediaquery } from '@kit.ArkUI';
/** 栅格断点类型 */
export type GridBreakpointType = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
/** 监听项内部管理接口 */
interface MediaQueryListenerItem {
listener: mediaquery.MediaQueryListener;
callback: (result: mediaquery.MediaQueryResult) => void;
}
export class MediaQueryUtil {
private static instance: MediaQueryUtil | null = null;
private uiContext?: UIContext;
private listenerMap: Map<string, MediaQueryListenerItem> = new Map();
private breakpoints: number[] = [320, 600, 840, 1440, 1600];
private constructor() {}
static getInstance(): MediaQueryUtil {
if (!MediaQueryUtil.instance) {
MediaQueryUtil.instance = new MediaQueryUtil();
}
return MediaQueryUtil.instance;
}
/**
* 初始化工具类
* @param uiContext 应用上下文
* @param customBreakpoints 自定义栅格断点(单位:vp)
*/
init(uiContext: UIContext, customBreakpoints?: number[]): void {
if (!this.uiContext) {
this.uiContext = uiContext;
}
if (customBreakpoints?.length) {
this.breakpoints = [...customBreakpoints];
}
}
/**
* 注册监听
*/
private register(
key: string,
condition: string,
onChange: (isMatch: boolean) => void
): void {
if (!this.uiContext) {
throw new Error('MediaQueryUtil 未初始化,请先调用 init(uiContext)');
}
this.removeListener(key);
const listener = this.uiContext.getMediaQuery().matchMediaSync(condition);
const callback = (result: mediaquery.MediaQueryResult) => {
onChange(!!result.matches);
};
listener.on('change', callback);
this.listenerMap.set(key, { listener, callback });
onChange(!!listener.matches);
}
/**
* 移除单个监听
*/
private removeListener(key: string): void {
const item = this.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.uiContext = undefined;
MediaQueryUtil.instance = null;
}
/**
* 监听栅格断点变化
* @param onChange 断点变化回调
*/
onBreakpointChange(onChange: (breakpoint: GridBreakpointType) => void): void {
const bps = this.breakpoints;
const update = () => {
const mq = this.uiContext!.getMediaQuery();
let bp: GridBreakpointType = 'xs';
if (mq.matchMediaSync(`(width >= ${bps[4]}vp)`).matches) bp = 'xxl';
else if (mq.matchMediaSync(`(width >= ${bps[3]}vp)`).matches) bp = 'xl';
else if (mq.matchMediaSync(`(width >= ${bps[2]}vp)`).matches) bp = 'lg';
else if (mq.matchMediaSync(`(width >= ${bps[1]}vp)`).matches) bp = 'md';
else if (mq.matchMediaSync(`(width >= ${bps[0]}vp)`).matches) bp = 'sm';
onChange(bp);
};
this.register('internal_bp', '(width >= 0vp)', update);
}
}
这个工具类的设计思路:
- 单例模式:全局共享,避免重复创建监听
- 断点阈值可自定义:默认
[320, 600, 840, 1440, 1600],对应 xs/sm/md/lg/xl/xxl - 资源安全:页面销毁时调用
removeAllListeners避免内存泄漏
三、登录页完整代码
有了工具类,登录页只需要做三件事:初始化、注册监听、清理监听。
// pages/LoginPage.ets
import { MediaQueryUtil, GridBreakpointType } from '../utils/MediaQueryUtil';
@Entry
@Component
struct LoginPage {
// 断点状态(仅调试用)
@State breakpoint: GridBreakpointType = 'sm';
// 栅格配置 —— 由断点驱动
@State spanConfig: GridColColumnOption = { xs: 12, sm: 12, md: 6, lg: 6, xl: 6, xxl: 6 };
@State offsetConfig: GridColColumnOption = { xs: 0, sm: 0, md: 3, lg: 3, xl: 3, xxl: 3 };
@State cardHorizontalPadding: number = 30;
// 表单数据
@State username: string = '';
@State password: string = '';
aboutToAppear(): void {
const util = MediaQueryUtil.getInstance();
util.init(this.getUIContext());
// 一行代码注册断点监听
util.onBreakpointChange((bp: GridBreakpointType) => {
this.breakpoint = bp;
this.updateConfig(bp);
});
}
aboutToDisappear(): void {
// 清理所有监听
MediaQueryUtil.getInstance().removeAllListeners();
}
private updateConfig(bp: GridBreakpointType): void {
const isMobile = bp === 'xs' || bp === 'sm';
const span = isMobile ? 12 : 6;
const offset = isMobile ? 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.cardHorizontalPadding = isMobile ? 30 : 0;
}
@Builder
LoginCard() {
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.username = v })
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.password = v })
}
.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: 6,lg、xl 会自动继承。但实际运行时,窗口在断点边界反复横跳时,继承链可能导致 UI 出现一帧“中间态”。
手动为所有断点统一赋值,换来的是响应确定性。 @State 变量整体替换,ArkUI 检测到引用变化,一次刷新到位,没有闪烁。
4.2 为什么用 @State 而不是直接传对象?
// ❌ 每次 updateConfig 都创建新对象,但 UI 不刷新
private spanConfig = { xs: 12, sm: 12, md: 6, lg: 6, xl: 6, xxl: 6 };
// ✅ 用 @State,变化触发 UI 刷新
@State spanConfig: 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.titleFontSize = bp === 'xs' || bp === 'sm' ? 24 : 32;
});
// 自定义断点阈值
util.init(this.getUIContext(), [360, 640, 880, 1280, 1600]);
核心公式不变:媒体查询 → 状态变量 → 任意 UI 属性。
七、避坑清单
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 断点监听不生效 | 工具类未初始化 | aboutToAppear 中先调用 init(this.getUIContext()) |
| 页面销毁后仍有回调 | 监听未清理 | aboutToDisappear 中调用 removeAllListeners() |
| 断点变了 UI 不变 | 配置对象不是 @State |
spanConfig 和 offsetConfig 加 @State |
| 卡片不居中 | offset 算错 | 统一用 (12 - span) / 2 |
| 大屏卡片太宽或太窄 | span 值不合适 | 6 列最舒适,不要用 4 列或 8 列 |
八、总结
登录页适配的核心不是“把东西填满”,而是“把东西放在它该在的位置上”。
更多推荐



所有评论(0)