跨端应用开发中,登录页适配是个看似简单、实则容易翻车的活。

设计师给了一套手机尺寸的设计稿——卡片居中、输入框舒适、按钮大小刚好。你照着实现,在手机上跑起来确实漂亮。然后你把应用拖到平板模拟器上——登录卡片像被吹了气一样膨胀,输入框横跨整个屏幕,按钮宽得像一堵墙。

产品经理走过来说:“让它在大屏上也显得合适一点。”

“合适”——这个词意味着不是平铺拉满,也不是固定死板,而是根据屏幕大小自动调整到舒适的视觉状态

本文分享一个具体的实现方案:手机屏幕让卡片撑满但保留呼吸感,平板及以上屏幕让卡片收窄到 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])
  }
}

不同屏幕尺寸上运行效果

登录页面媒体查询响应式布局.gif

四、三个关键设计决策

4.1 为什么所有断点要逐个赋值?

ArkUI 的 GridColColumnOption 支持断点继承——写了 md: 6lgxl 会自动继承。但实际运行时,窗口在断点边界反复横跳时,继承链可能导致 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 spanConfigoffsetConfig@State
卡片不居中 offset 算错 统一用 (12 - span) / 2
大屏卡片太宽或太窄 span 值不合适 6 列最舒适,不要用 4 列或 8 列

八、总结

登录页适配的核心不是“把东西填满”,而是“把东西放在它该在的位置上”。

Logo

一站式 AI 云服务平台

更多推荐