做uniapp这几年,我几乎每个项目都会被“页面自适应”缠住一段时间。尤其当一套代码要同时跑在App、微信小程序、H5上,不同设备尺寸、不同浏览器内核、不同导航栏高度混在一起,页面上那些“看起来没问题”的布局,换个机型就歪得没法看。这篇博文我就把自己在Uniapp里做页面自适应的完整思路、踩过的坑和最终落地的方案整理出来,主要围绕单位选型、布局拆分、动态计算、高频场景适配和跨端调试这几块展开,希望能帮你少走点弯路。
先说一下这篇文章适合谁:正在用Uniapp做多端应用、被屏幕尺寸和平台差异折磨的开发者,或者刚接触Uniapp、想知道“为什么别人写的页面怎么换设备都不乱”的新手。我会尽量讲得直白一点,但前提是你对Vue语法和Uniapp基础生命周期有基本概念,如果连template和script都还没分清,建议先随便跑通一个官方模板再回来看。
1. 动手前先想清楚:页面自适应到底在解决什么问题
1.1 从“一稿适配三端”的痛点说起
很多同学一上来就搜“Uniapp自适应”,然后开始折腾rpx、vw、rem这些单位,但半天搞不定,核心原因其实是没想清楚自己面对的到底是什么问题。Uniapp的自适应,表面看是“让元素尺寸随屏幕变化”,本质上却是要同时处理三类矛盾:第一,不同屏幕物理尺寸之间的比例矛盾;第二,不同平台对CSS支持程度带来的渲染差异;第三,不同运行环境(比如浏览器内核、WebView)对交互行为的影响。
我举个真实例子:同样是底部一个按钮,在iPhone SE上宽度写死375px,到了iPhone 14 Pro Max还是375px,看起来还算正常,但如果放到折叠屏或者平板上,按钮就变成了“小油条”,丑得离谱;反过来,如果你用百分比,按钮宽度倒是跟着父容器走了,可按钮里的字体、图标、间距又是一堆写死的值,照样不协调。所以,页面自适应不是某一种单位能解决的问题,它需要你在“尺寸单位”“布局方式”“容器计算”三个层面同时发力。
Uniapp做自适应的另一层难点是它的编译策略。同一个.vue文件,编译到小程序端、App端、H5端,渲染原理完全不同:小程序有自己的一套WXSS规范,App端如果是vue页面则走WebView渲染,如果是nvue页面则走原生渲染,H5就是普通浏览器。这导致很多CSS属性在某些端可用的、在另一些端直接被忽略,你如果只用一个端调试,很容易在发布后翻车。
1.2 单页面自适应与全局自适应的边界划分
这里要区分两个概念:单页面自适应,指的是某个页面内部元素之间能适配不同尺寸;全局自适应,指的是整个应用的导航栏、Tabbar、字体、间距风格在所有页面都保持统一协调。很多人做自适应,只盯着某个页面狂调样式,结果页面本身没问题,一到tab切换或者跳转后,导航栏忽高忽低、底栏错位,体验全毁。
所以我的习惯是:做项目的第一步,先把全局样式变量定下来。Uniapp里可以借助uni.scss定义全局scss变量,比如把主色调、间距基数、字号阶梯都放进去,页面里直接用变量引用。这样后续做自适应时,你只需要改变量就能控制全局风格变化,而不需要逐个页面去调。
全局自适应的关键还有两个地方:一个是navigationBar的样式处理,一个是底部安全区。navigationBar如果使用默认原生导航,它的高度在不同平台表现不一样,尤其App端可以自定义,小程序端又分胶囊位置,这个时候不能写死高度,要结合系统信息动态计算。底部安全区则需要考虑iPhone的home indicator以及其他品牌手机的底部手势条,单纯padding-bottom: 10px肯定不够。
2. 单位选型对比:为什么rpx不够用,vw/vh、rem何时上场
2.1 三种单位的优缺点与换算关系
做Uniapp自适应,rpx是绕不开的起点,因为小程序官方就是拿它来做适配的。rpx的换算公式是:屏幕宽度 = 750rpx,也就是说,你在设计稿上量出一个375px的宽度,写在rpx里就是375rpx,小程序会自动按当前屏幕宽度比例缩放。这个机制在小程序里非常流畅,因为小程序的渲染层本身支持rpx单位,但在H5和App的WebView里,rpx是由Uniapp编译时通过计算转成rem、px或者vw实现的,具体转法跟平台有关。
单纯用rpx的问题在于,它是按“屏幕宽度等比例缩放”来设计的,这会导致一种“等比失真”:在很宽的大屏设备上,整个页面元素被放大得非常夸张,看起来就像老年机字体。拿rpx来做小元素(按钮高度、内边距、图标尺寸)非常合适,但如果整页所有东西都用rpx,到大屏设备就很容易失控。
vw/vh是CSS原生视口单位,Uniapp的H5和App-H5端支持很成熟,小程序端的WebView也支持,但在小程序的WXML里它们表现并不稳定,部分组件会忽略vw单位。因此vw/vh适合用来做页面级容器的高度和宽度,比如一个半屏弹层、一个全屏背景,这个场景它比rpx更可靠。rem则是传统的H5适配方案,Uniapp项目里通常不直接用rem做主单位,除非你用了第三方UI库或者纯H5运行环境,否则没必要引入额外的rem计算逻辑。
我直接把三者的对比放在一起,方便你选型:
| 单位 | 适配基准 | 适用场景 | 常见坑点 |
|---|---|---|---|
| rpx | 屏幕宽度 750rpx 等比缩放 | 组件内部尺寸、按钮、边距、图标 | 大屏会整体放大,跨界失真 |
| vw/vh | 视口宽高百分比 | 页面级容器、全屏弹层、背景区域 | 小程序部分组件支持不稳定 |
| rem | 根字体大小 | H5端大规模页面风格控制 | 需要动态设置根字体,与小程序兼容差 |
| 百分比 | 父容器相对比例 | 栅格系统、流式布局 | 依赖父容器高度,容易塌陷 |
| px | 固定像素 | 1px边框、不被缩放的特殊场景 | 多端必失真,尽量避免大面积使用 |
2.2 混合使用单位和布局的实战建议
很多人希望找到“万能单位”,然后用它一套到底,我明确告诉你这个思路行不通。我目前的项目里,用的是“rpx为主、百分比为辅、vw/vh做容器级控制”的混合方案,具体规则是:组件内部的宽高、内边距、圆角、字号,全部用rpx;网格或者多列布局,优先用百分比计算宽度;全屏弹层、遮罩、底部抽屉这类大容器,直接上vw/vh或100%;边框和必须保持物理像素清晰的线,用固定px。
混合使用单位时最容易出的问题,是父子容器之间单位不一致导致的计算误差。举例来说,一个卡片宽度是75%,内部按钮宽度你写了200rpx,这样并没什么问题,因为按钮宽度会根据屏幕宽度等比缩放,但如果你期望按钮宽度“占卡片宽度的80%”,用rpx就没法直接实现,只能先根据窗口宽度计算75%对应的rpx,再乘以0.8,非常麻烦。所以碰到这种强关联的尺寸,我建议直接用百分比,让父容器决定子元素的相对大小。
还有一点要注意:Uniapp在编译到不同端时,对rpx的处理不完全一样。比如App-vue页面里,rpx最终会转成px,而小程序里它是直接保留rpx单位。所以你在开发调试时用模拟器看着没问题,不代表真机上没问题,得在真机上逐个端过一遍。
3. 布局层面的核心方案:flex、栅格与动态计算
3.1 一套栅格系统的落地
解决了单位选型后,下一步就是要搭一套能复用的布局基础。我强烈建议你在项目里内置一个简单的栅格系统,不要每个页面都从零开始写Flex布局。为什么?因为页面自适应最难的不是单个页面,而是页面之间、列表与列表之间的对齐问题。如果没有统一的栅格,A页面的卡片左边距是16px,B页面的列表左边距是20px,视觉上整个App就乱了。
栅格系统的实现可以很简单,不需要引第三方库,自己在scss里定义两组类即可:
// 栅格行容器 .flex-row { display: flex; flex-wrap: wrap; box-sizing: border-box; } // 列:按24等分计算宽度 @for $i from 1 through 24 { .col-#{$i} { width: percentage($i / 24); box-sizing: border-box; padding-left: 10rpx; padding-right: 10rpx; } }这组代码会生成col-1到col-24的列类,宽度比例从1/24到100%。之所以选24等分,是因为它能被2、3、4、6、8、12整除,做各种分栏都比较方便。使用的时候只需要:
<view class="flex-row"> <view class="col-12">左侧半屏</view> <view class="col-12">右侧半屏</view> </view>这里有个细节:我给每个col都加了左右10rpx的padding,目的是在列之间形成固定间距。但如果你要做通栏卡片,记得在外层容器加横向负边距抵消掉这个padding,否则卡片两侧会出现“呼吸缝”。这个负边距的数值要跟padding保持一致,动态栅格才能对齐。
栅格系统我最推荐的用法,是配合列表页的卡片布局和表单的双列布局。特别是在表单里,用户输入框的宽度如果直接写50%,在窄屏下容易出现两个输入框挤成一团,用栅格类就可以很灵活地控制“手机号占一格、验证码占一格”的排列方式。做自适应页面,栅格系统相当于给你的布局上了保险,后期新增页面时省掉的调试时间非常可观。
3.2 动态计算屏幕尺寸与安全区域
栅格能解决水平方向的等比划分,但处理不了“跟真实像素强相关的”场景,比如顶部导航栏高度、底部安全区域、iPhone刘海,这些必须动态计算。Uniapp里最常用的接口是uni.getSystemInfoSync,可以拿到屏幕宽高、状态栏高度、导航栏高度等信息。
我这里给出一段我在工具函数里封装的代码,用来统一从系统信息里取值:
// utils/system.js let systemInfo = null; export function getSystemInfo() { if (systemInfo) return systemInfo; try { systemInfo = uni.getSystemInfoSync(); } catch (e) { systemInfo = { screenWidth: 375, screenHeight: 667, statusBarHeight: 20, platform: 'devtools' }; } return systemInfo; } export function getStatusBarHeight() { return getSystemInfo().statusBarHeight || 20; } export function getNavBarHeight() { // 胶囊按钮在大多数端的默认高度约32px,顶部加上状态栏后就是导航栏总高 const statusBarHeight = getStatusBarHeight(); const capsuleHeight = 32; const capsuleTopOffset = 6; return statusBarHeight + capsuleHeight + capsuleTopOffset * 2; } export function getSafeBottomHeight() { const sys = getSystemInfo(); if (sys.platform === 'ios' && sys.screenHeight >= 812) { return 34; } if (sys.platform === 'android') { // 部分安卓有底部导航条,具体值需要真机获取 return 0; } return 0; }为什么这里要封装而不是每个页面都调uni.getSystemInfoSync?因为获取系统信息本身是一个消耗性能的异步过程,频繁调用还有可能在极端情况下触发警告,封装成单例后,每个页面拿到的都是同一份缓存数据,也保证所有页面导航高度一致。
有了这个工具,页面里就可以动态设置顶部占位:
<view class="custom-nav" :style="{ paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px' }"> <view class="nav-title">页面标题</view> </view>import { getStatusBarHeight, getNavBarHeight } from '@/utils/system.js'; export default { data() { return { statusBarHeight: getStatusBarHeight(), navBarHeight: getNavBarHeight() }; } };这里有一个比较隐蔽的坑:在支付宝小程序里,uni.getSystemInfoSync返回的statusBarHeight偶尔会是0,特别是页面刚启动时。所以我在封装里catch了异常,给了兜底值。但兜底值也分平台,如果你在App端,状态栏高度通常不会低于20,而在安卓全面屏手机可能达到30甚至更高。稳妥的做法是,在页面onLoad里再重新获取一次系统信息,如果你有需要,也可以把systemInfo缓存清掉强制刷新,只是要注意别在渲染前拿到空值。
底部安全区的适配,我推荐直接用Uniapp内置的env(safe-area-inset-bottom),这个属性在App和H5的WebView里都能原生解析,比自己拿机型判断要可靠得多。但如果你的项目要兼容低版本安卓WebView,这个属性会失效,此时再用js动态计算兜底:
.safe-bottom { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }4. 高频场景适配实录:键盘遮挡、弹窗滚动与视频列表
4.1 输入框被软键盘遮挡的3种解除办法
页面自适应不只是“尺寸”的事,交互上的适配更影响使用体验。最常见也最让开发者头疼的问题,就是你输入内容时软键盘弹起来,把输入框挡得严严实实,看不到自己在敲什么。这个问题在微信小程序和App里表现还不一样:小程序里键盘弹起会触发页面自动顶起,但顶起后输入框却可能跑到键盘下方;App-H5里输入框会被键盘直接覆盖;iOS Safari里还经常出现键盘收起后页面高度不回弹的“上顶”问题。
先说小程序端。默认情况下,小程序页面键盘弹起后会触发adjust-position机制,把webview往上推。但如果你在input上设置了cursor-spacing(光标与输入框底部距离),就相当于自定义了输入框与键盘的间距。我建议把cursor-spacing设置成20,单位是px,不要用rpx,因为它内部就是按物理像素参与布局的。如果设置了还不行,多半是页面里有fixed定位的底部提交按钮,键盘弹起时把它一起顶上去遮挡了输入框。
再说App端。App端vue页面就是WebView渲染,输入框被遮挡的原因是WebView自己处理不了键盘避让,需要uni.pageScrollTo配合监听键盘高度来手动调整。核心逻辑是:输入框获得焦点时,监听uni.onKeyboardHeightChange获取键盘高度,然后计算输入框底边距与键盘顶部的距离,如果重叠,就把页面滚上去:
onFocus() { uni.onKeyboardHeightChange(res => { this.keyboardHeight = res.height; }); }这个事件的取值误差比较大,尤其是首次弹出键盘时,res.height可能短暂为0,建议在回调里加个防抖或延时处理。还有,如果页面里同时存在多个输入框,你需要根据当前聚焦的输入框位置重新计算滚动距离,否则会出现“留白不对”或者“滚过头”的问题。
最后是iOS Safari H5端。很多朋友反馈设置了adjust-position也没用,这确实是无解的,因为H5里压根没有这个属性。我的解决方案是监听输入框focus事件,用setTimeout延迟300ms调用window.scrollTo来滚动到输入框位置,同时在blur事件里把页面滚回顶部。这个方法在iOS上有奇效,但要注意不能污染页面的正常滚动逻辑,否则用户手动下拉刷新会受影响。
4.2 popup弹窗背后的页面跟着滚动,怎么解决
做Uniapp页面,弹窗是少不了的组件,所以不可避免会遇到一个经典问题:弹出popup后,底层的页面还在滚动。这在小程序端尤其容易发生,因为小程序页面的滚动容器和弹层的滚动容器如果没有隔离,触摸事件会同时作用。Uniapp的popup组件通常是基于uni-popup或者自己封装的组件,默认行为确实不会去锁定底层滚动。
解决这个问题的第一层方案,是在弹窗打开时给页面容器加上overflow: hidden,或者给body设置高度固定:
.page-scroll-lock { height: 100vh; overflow: hidden; }在小程序里,直接改页面根节点的style是可以生效的,但要注意,如果你的页面是用scroll-view包裹的滚动容器,得把锁定样式加到scroll-view上,而不是外层view。因为小程序的页面滚动如果发生在scroll-view内,外层overflow:hidden并不会阻断它。
第二次层方案是利用catchtouchmove阻止事件冒泡。在弹窗遮罩层上加上catchtouchmove的空处理函数,可以阻止底层的触摸滚动:
<view class="popup-mask" catchtouchmove="noop"> <view class="popup-body">弹窗内容</view> </view>methods: { noop() {} }这个方案在小程序端很可靠,因为catch修饰符会终止事件传播。但在H5端效果稍弱,因为H5的事件机制和小程序不一样,你还需要同时给body加锁。所以我通常是把两层方案组合使用:条件编译判断平台,小程序端用catchtouchmove,H5和App端用class锁。
还有个场景容易被忽略:弹窗内部本身有滚动区域,这时候如果直接用overflow:hidden锁住页面,弹窗内部的滚动条会变得非常卡顿,滚动到底部时还会触发“橡皮筋”效果。我习惯的做法是,弹窗内容区域用scroll-view包一层,并传入scroll-y参数,同时在弹窗打开时记录底层页面的scrollTop值,关闭弹窗时恢复这个值。这样既解决了底层滚动穿透,又不影响弹窗内部滚动。
4.3 视频列表:单视频播放与滑出可视区自动暂停
视频类页面是自适应里比较硬核的场景,因为视频有固定宽高比,不同设备屏幕宽度下,视频容器高度会变化,还要处理列表滚动时的播放状态。我之前做过一个视频列表需求,要求“同时只能有一个视频播放,视频滑出可视区自动暂停”,这个需求对页面自适应和数据状态同步都有挑战。
先看视频容器的尺寸适配。视频一般用video标签,宽高比通常是16:9,你可以直接把外层容器宽度设置100%,然后高度用aspect-ratio属性,或者用传统的padding-bottom技巧:
.video-wrapper { width: 100%; position: relative; padding-bottom: 56.25%; /* 16:9 */ } .video-wrapper video { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }在微信小程序里,video组件是一个原生组件,层级很高,普通view很难覆盖在它上面。所以要特别注意,视频上方的遮罩和自定义UI不能直接放普通view,得用cover-view。这是视频组件适配和普通组件适配最大的区别点。
再看自动暂停逻辑。监听页面滚动时,需要获取每个视频容器的位置,判断它是否还在可视区域内。这里需要用到uni.createSelectorQuery()来批量查询视频容器的boundingClientRect:
export function isElementInViewport(rect) { return rect.top >= 0 && rect.bottom <= uni.getSystemInfoSync().screenHeight; }实际开发中,复杂一点的做法是,监听滚动时遍历所有视频容器,找到可视区域内最靠上的视频,然后播放它,其余全部暂停。这里要注意性能,不能用setData频繁更新整个列表状态,我建议用uni.createIntersectionObserver来监听每个视频与视口的交叉状态,性能要好很多,并且小程序端原生支持:
this.videoObserver = uni.createIntersectionObserver(this); this.videoObserver .relativeToViewport() .observe('.video-item', (res) => { if (res.intersectionRatio > 0.5) { // 该视频进入可视区域超过50%,触发播放 } });这个接口在小程序端兼容性不错,App端视基础库版本而定。有一点容易踩坑:IntersectionObserver在视频列表首屏加载时,如果页面滚动还没发生,它不一定会主动触发一次回调,需要你手动调用一次,把第一个可视视频初始化为播放状态。否则用户看到的第一个视频就是静默的。
5. 图表、地图等第三方组件的自适应处理
5.1 ECharts在Uniapp中的resize策略
业务页面很少有纯文本的,图表和地图的高频出现,让自适应又往前走了一步。ECharts是前端图表标杆,在Uniapp里一般通过renderjs或者自定义组件的方式集成。集成难点在于:图表初始化后,如果容器尺寸变化,图表的canvas不会自动跟着变化,需要手动调用resize方法。
我的做法是在图表组件里监听容器尺寸变化。web端可以用ResizeObserver,小程序端没有这个API,就只能靠页面onResize事件配合窗口尺寸变化来触发。Uniapp里有一个uni.onWindowResize,可以监听窗口尺寸变化,比如横竖屏切换:
onLoad() { uni.onWindowResize((res) => { this.chart && this.chart.resize(); }); }但实际的尺寸变化不完全靠窗口,比如侧滑菜单展开收起、折叠面板展开,这些都会改变图表容器的宽度,但窗口尺寸没变。这个时候用ResizeObserver监听容器才是正解,前提是运行环境是H5或者App-vue页面:
// 仅H5/App端可用 const observer = new ResizeObserver(() => { chart.resize(); }); observer.observe(chartDom);还有一个细节,图表数据更新后经常出现“容器尺寸对了但图形错位”的现象,这是因为ECharts内部的布局计算依赖容器的clientWidth,而你在设置数据时容器可能还没完成重排。稳妥做法是setData或者更新数据后,用nextTick加一个200ms的延时,再调用resize。不然图表会在一个旧的尺寸上重新渲染,自适应做得再好也白搭。
5.2 地图容器的高度适配
地图组件在高德地图和腾讯地图的Uniapp插件里都很常用,这里最容易出现的自适应问题是,地图容器高度如果写死,在长屏手机上显得空,在矮屏手机上则把其他内容挤出屏幕。我建议地图容器高度动态计算:用页面的总高度减去上下固定占位的高度,剩下的给地图。
比如一个页面顶部有搜索栏、底部有操作按钮,中间是地图区域:
// data里定义 mapHeight: 0 // 计算 const sys = uni.getSystemInfoSync(); const topHeight = 120; // 顶部搜索栏高度(px) const bottomHeight = 100; // 底部按钮高度(px) this.mapHeight = sys.screenHeight - topHeight - bottomHeight;这个方案的问题在于,你并不知道中间是否有其他固定元素,所以更通用的是用flex布局,让地图容器flex:1,这样不管屏幕怎么变化,自适应都是自动的:
<view class="page"> <view class="search-bar">搜索栏</view> <view class="map-container"> <map :style="{ width: '100%', height: '100%' }" /> </view> <view class="bottom-bar">底部按钮</view> </view>.page { display: flex; flex-direction: column; height: 100vh; } .map-container { flex: 1; min-height: 200px; }这里加min-height是为了防止在极端小屏设备上地图被压缩到不可用。使用map组件时,还要留意原生组件的层级问题,在地图上方覆盖搜索框或者标记物时,需要cover-view,跟video组件类似。如果不做层级处理,你在地图上放一个普通view的搜索框,可能直接被地图盖住,不管怎么调z-index都没用。
6. 实战收尾:跨端调试与发布前的最后检查
6.1 多端联调的效率工具顺序
自适应做得再好,不经过多端验证都等于零。我的调试顺序有一个固定流程,先从微信开发者工具开始,因为它模拟器反应最快,能迅速发现大部分CSS兼容问题;然后跑浏览器H5模式,用宽屏、窄屏的模拟窗口过一遍栅格布局;最后是用HBuilderX的App真机运行,重点看iPhone和一款安卓全面屏手机。不要跳过浏览器这步,很多“小程序真机才有的问题”,其实在H5模式下就能提前暴露。
真机调试时,我会打开开发者工具里的元素检查,把页面里所有固定px大小的元素拉出来逐一审查。一般我给自己定的规则:除了1px边框以外,固定px只能在动态计算的导航高度、安全区高度里出现,其余一律换成rpx、百分比或者vw/vh。如果你的页面里大面积的px,那你还没吃透自适应。
6.2 上架前适配相关的检查清单
这里分享一份我自己整理的“上架前最后检查清单”,每次发布前都会逐条过一遍:
| 检查项 | 检查标准 | 可能踩坑点 |
|---|---|---|
| 全局单位 | 组件尺寸rpx化,页面容器vw/h | 字体内边距混用px导致歪 |
| 导航栏高度 | 动态获取状态栏+胶囊高度 | 各端状态栏高度差异 |
| 底部安全区 | env(safe-area-inset-bottom)兜底 | 安卓真机底部手势条不触发 |
| 弹窗滚动 | 打开时锁定底层滚动 | popup内滚动卡顿 |
| 输入框键盘 | cursor-spacing + 键盘监听 | iOS Safari上顶回弹 |
| 视频/地图 | cover-view覆盖、容器flex:1 | 原生组件层级高 |
| 横竖屏切换 | uni.onWindowResize触发resize | ECharts不自动重绘 |
| 字体大小 | 不跟随系统字体缩放失真 | 部分安卓字体加粗 |
这份清单不用全改,但一定要逐条检查。很多时候,你觉得自己代码写得很对,一上真机还是有边框超出屏幕、弹层位置偏移这类小毛病,原因就是漏了某一条。
我个人在实际项目里的体会是:页面自适应不是一个一次性的“技术方案”,而是一种贯穿项目始终的编码习惯。从单位选型到布局拆分,从系统信息的动态取值到每个场景的兜底方案,都需要在写每一行样式时脑子里多根弦。如果你现在正被Uniapp的适配问题缠住,别急着改代码,先回到根上去想“这个尺寸到底该由谁决定”,往往思路就通了。后续如果你有更复杂的自适应场景,比如多端折叠屏适配、平板横屏的应用布局,还可以在栅格系统上继续扩展,那就更有意思了。