平台适配
uni-app是一个使用Vue.js开发跨平台应用的前端框架,可以同时运行在iOS、Android、H5以及各种小程序平台。本文将详细介绍uni-app的跨平台适配策略和最佳实践,帮助开发者高效地进行多平台开发。
条件编译
条件编译是uni-app中用于处理平台差异的重要机制,可以根据不同平台编译不同的代码。
条件编译语法
1. 使用 #ifdef 和 #ifndef 进行条件编译
js
// #ifdef 仅在某平台编译
// #ifdef APP-PLUS
console.log('这段代码只在App平台编译');
// #endif
// #ifndef 除了某平台均编译
// #ifndef MP-WEIXIN
console.log('这段代码不会在微信小程序平台编译');
// #endif
2. 支持的平台
值 | 平台 |
---|---|
APP-PLUS | App |
APP-PLUS-NVUE | App nvue |
H5 | H5 |
MP-WEIXIN | 微信小程序 |
MP-ALIPAY | 支付宝小程序 |
MP-BAIDU | 百度小程序 |
MP-TOUTIAO | 字节跳动小程序 |
MP-QQ | QQ小程序 |
MP-KUAISHOU | 快手小程序 |
MP | 所有小程序 |
QUICKAPP-WEBVIEW | 快应用通用 |
QUICKAPP-WEBVIEW-UNION | 快应用联盟 |
QUICKAPP-WEBVIEW-HUAWEI | 快应用华为 |
3. 多平台条件编译
js
// #ifdef APP-PLUS || H5
console.log('这段代码只在App和H5平台编译');
// #endif
条件编译应用场景
1. 在 template 中使用条件编译
html
<template>
<view>
<!-- #ifdef APP-PLUS -->
<view>这是App特有的组件</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view>这是微信小程序特有的组件</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view>这是H5特有的组件</view>
<!-- #endif -->
<!-- 在所有平台都显示的内容 -->
<view>这是所有平台都显示的内容</view>
</view>
</template>
2. 在 script 中使用条件编译
js
<script>
export default {
data() {
return {
platformInfo: ''
}
},
onLoad() {
// #ifdef APP-PLUS
this.platformInfo = '当前是App平台';
// 调用App特有的API
const currentWebview = this.$scope.$getAppWebview();
// #endif
// #ifdef MP-WEIXIN
this.platformInfo = '当前是微信小程序平台';
// 调用微信小程序特有的API
wx.getSystemInfo({
success: (res) => {
console.log(res);
}
});
// #endif
// #ifdef H5
this.platformInfo = '当前是H5平台';
// 调用H5特有的API
document.addEventListener('click', () => {
console.log('H5点击事件');
});
// #endif
},
methods: {
// #ifdef APP-PLUS
appMethod() {
// App平台特有方法
}
// #endif
}
}
</script>
3. 在 style 中使用条件编译
css
<style>
/* 所有平台通用样式 */
.content {
padding: 15px;
}
/* #ifdef APP-PLUS */
/* App平台特有样式 */
.app-content {
background-color: #007AFF;
}
/* #endif */
/* #ifdef MP-WEIXIN */
/* 微信小程序平台特有样式 */
.mp-content {
background-color: #04BE02;
}
/* #endif */
/* #ifdef H5 */
/* H5平台特有样式 */
.h5-content {
background-color: #FC0;
}
/* #endif */
</style>
4. 整体条件编译
可以对整个文件进行条件编译,例如创建平台特有的页面或组件:
- 特定平台的页面:pages/login/login.app.vue(仅在App中编译)
- 特定平台的组件:components/ad/ad.mp.vue(仅在小程序中编译)
5. 在 static 目录中的条件编译
static 目录下的文件不会被编译,但可以通过目录名称实现条件编译:
┌─static
│ ├─app-plus # 仅app平台生效
│ │ └─logo.png
│ ├─h5 # 仅H5平台生效
│ │ └─logo.png
│ ├─mp-weixin # 仅微信小程序平台生效
│ │ └─logo.png
│ └─logo.png # 所有平台生效
条件编译最佳实践
1. 抽离平台差异代码
将平台特有的代码抽离到单独的文件中,通过条件编译引入:
js
// platform.js
// #ifdef APP-PLUS
export const platform = {
name: 'APP',
// App平台特有方法和属性
share: function(options) {
uni.share({
provider: 'weixin',
...options
});
}
};
// #endif
// #ifdef MP-WEIXIN
export const platform = {
name: 'MP-WEIXIN',
// 微信小程序平台特有方法和属性
share: function(options) {
wx.showShareMenu({
withShareTicket: true,
...options
});
}
};
// #endif
// #ifdef H5
export const platform = {
name: 'H5',
// H5平台特有方法和属性
share: function(options) {
// H5分享逻辑
}
};
// #endif
然后在业务代码中统一调用:
js
import { platform } from './platform.js';
// 调用平台特有的分享方法
platform.share({
title: '分享标题',
content: '分享内容'
});
2. 使用统一的API封装
为不同平台的特性提供统一的API封装:
js
// api.js
/**
* 获取位置信息
* @returns {Promise} 位置信息Promise
*/
export function getLocation() {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
plus.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
});
},
(error) => {
reject(error);
},
{ timeout: 10000 }
);
// #endif
// #ifdef MP-WEIXIN
wx.getLocation({
type: 'gcj02',
success: (res) => {
resolve({
latitude: res.latitude,
longitude: res.longitude,
accuracy: res.accuracy
});
},
fail: (err) => {
reject(err);
}
});
// #endif
// #ifdef H5
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
});
},
(error) => {
reject(error);
},
{ timeout: 10000 }
);
} else {
reject(new Error('浏览器不支持Geolocation API'));
}
// #endif
});
}
3. 避免过度使用条件编译
过度使用条件编译会导致代码难以维护。应尽量减少条件编译的使用,优先考虑以下方案:
- 使用uni-app提供的跨平台API
- 抽象出平台差异,提供统一的接口
- 使用组件化思想,将平台特有的功能封装为组件
样式适配
1. 屏幕适配
uni-app支持基于750rpx屏幕宽度的自适应单位,可以在不同尺寸的屏幕上实现一致的布局效果。
css
.container {
width: 750rpx; /* 满屏宽度 */
padding: 30rpx; /* 内边距 */
}
.card {
width: 690rpx; /* 750rpx - 30rpx * 2 */
height: 300rpx;
margin-bottom: 20rpx;
}
2. 样式兼容性处理
不同平台对CSS的支持程度不同,需要注意样式兼容性:
css
/* 使用flex布局,兼容性较好 */
.flex-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
/* 避免使用小程序不支持的选择器 */
/* 不推荐: .parent > .child */
/* 推荐: */
.parent-child {
color: #333;
}
/* 使用条件编译处理平台特有样式 */
/* #ifdef H5 */
.special-style {
transition: all 0.3s;
}
/* #endif */
3. 安全区域适配
针对全面屏手机,需要处理安全区域:
css
/* 页面根元素 */
.page {
padding-bottom: constant(safe-area-inset-bottom); /* iOS 11.0 */
padding-bottom: env(safe-area-inset-bottom); /* iOS 11.2+ */
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}
/* 底部固定导航栏 */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(100rpx + constant(safe-area-inset-bottom));
height: calc(100rpx + env(safe-area-inset-bottom));
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
4. 暗黑模式适配
支持系统的暗黑模式:
css
/* 定义变量 */
page {
/* 浅色模式变量 */
--bg-color: #ffffff;
--text-color: #333333;
--border-color: #eeeeee;
}
/* 暗黑模式变量 */
@media (prefers-color-scheme: dark) {
page {
--bg-color: #1a1a1a;
--text-color: #f2f2f2;
--border-color: #333333;
}
}
/* 使用变量 */
.container {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
功能适配
1. 导航栏适配
不同平台的导航栏表现不同,需要进行适配:
html
<template>
<view class="page">
<!-- 自定义导航栏 -->
<!-- #ifdef APP-PLUS || H5 -->
<view class="custom-nav" :style="{ height: navHeight + 'px', paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<view class="back" @click="goBack">
<text class="iconfont icon-back"></text>
</view>
<view class="title">{{ title }}</view>
<view class="placeholder"></view>
</view>
</view>
<!-- #endif -->
<!-- 页面内容,根据平台调整内边距 -->
<view class="content" :style="contentStyle">
<!-- 页面内容 -->
</view>
</view>
</template>
<script>
export default {
data() {
return {
title: '页面标题',
statusBarHeight: 0,
navHeight: 0
}
},
computed: {
contentStyle() {
let style = {};
// #ifdef APP-PLUS || H5
style.paddingTop = this.navHeight + 'px';
// #endif
// #ifdef MP
// 小程序使用原生导航栏,不需要额外的内边距
// #endif
return style;
}
},
onLoad() {
this.initNavBar();
},
methods: {
initNavBar() {
const systemInfo = uni.getSystemInfoSync();
this.statusBarHeight = systemInfo.statusBarHeight;
// #ifdef APP-PLUS || H5
// App和H5使用自定义导航栏
this.navHeight = this.statusBarHeight + 44; // 状态栏高度 + 导航栏高度
// #endif
// #ifdef MP-WEIXIN
// 设置小程序原生导航栏
uni.setNavigationBarTitle({
title: this.title
});
// #endif
},
goBack() {
uni.navigateBack({
delta: 1
});
}
}
}
</script>
<style>
.page {
position: relative;
}
/* 自定义导航栏 */
.custom-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
background-color: #ffffff;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
}
.nav-content {
display: flex;
height: 44px;
align-items: center;
justify-content: space-between;
padding: 0 15px;
}
.back {
width: 30px;
height: 30px;
display: flex;
align-items: center;
}
.title {
font-size: 18px;
font-weight: 500;
}
.placeholder {
width: 30px;
}
/* 内容区域 */
.content {
width: 100%;
}
</style>
2. 底部安全区域适配
针对iPhone X等带有底部安全区域的设备:
html
<template>
<view class="page">
<!-- 页面内容 -->
<view class="content">
<!-- 内容 -->
</view>
<!-- 底部导航栏 -->
<view class="footer safe-area-bottom">
<view class="tab-item" v-for="(item, index) in tabs" :key="index" @click="switchTab(index)">
<view class="icon">
<text class="iconfont" :class="currentTab === index ? item.activeIcon : item.icon"></text>
</view>
<view class="text" :class="{ active: currentTab === index }">{{ item.text }}</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
currentTab: 0,
tabs: [
{ text: '首页', icon: 'icon-home', activeIcon: 'icon-home-fill' },
{ text: '分类', icon: 'icon-category', activeIcon: 'icon-category-fill' },
{ text: '购物车', icon: 'icon-cart', activeIcon: 'icon-cart-fill' },
{ text: '我的', icon: 'icon-user', activeIcon: 'icon-user-fill' }
]
}
},
methods: {
switchTab(index) {
this.currentTab = index;
}
}
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content {
flex: 1;
padding-bottom: 50px; /* 底部导航栏高度 */
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background-color: #ffffff;
display: flex;
box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.1);
}
.safe-area-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.icon {
font-size: 24px;
color: #999;
}
.text {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.text.active, .tab-item.active .icon {
color: #007AFF;
}
</style>
3. 键盘遮挡适配
处理键盘弹出时的页面适配:
html
<template>
<view class="page">
<form @submit="handleSubmit">
<view class="form-item">
<input class="input" type="text" v-model="username" placeholder="用户名" />
</view>
<view class="form-item">
<input class="input" type="password" v-model="password" placeholder="密码" />
</view>
<view class="form-item">
<button class="submit-btn" form-type="submit">登录</button>
</view>
</form>
</view>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
keyboardHeight: 0
}
},
onLoad() {
// #ifdef APP-PLUS
// 监听键盘高度变化
plus.key.addEventListener('showkeyboard', (e) => {
this.keyboardHeight = e.height;
});
plus.key.addEventListener('hidekeyboard', () => {
this.keyboardHeight = 0;
});
// #endif
},
methods: {
handleSubmit() {
// 处理表单提交
console.log('提交表单', this.username, this.password);
// 隐藏键盘
uni.hideKeyboard();
}
}
}
</script>
<style>
.page {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.input {
height: 90rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.submit-btn {
background-color: #007AFF;
color: #fff;
height: 90rpx;
line-height: 90rpx;
border-radius: 8rpx;
font-size: 32rpx;
}
/* #ifdef APP-PLUS */
/* 处理键盘弹出时的页面滚动 */
.page {
padding-bottom: 300rpx;
}
/* #endif */
</style>
平台特有功能适配
1. 分享功能适配
不同平台的分享功能实现方式不同:
js
// share.js
/**
* 通用分享方法
* @param {Object} options 分享参数
* @returns {Promise} 分享结果Promise
*/
export function shareContent(options) {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
// App平台分享
uni.share({
provider: options.provider || 'weixin', // 分享服务提供商
scene: options.scene || 'WXSceneSession', // 场景
type: options.type || 0, // 分享类型
title: options.title || '', // 标题
summary: options.summary || '', // 摘要
imageUrl: options.imageUrl || '', // 图片地址
href: options.href || '', // 跳转链接
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
// #endif
// #ifdef MP-WEIXIN
// 微信小程序分享
// 注意:微信小程序的分享需要在页面的onShareAppMessage生命周期中处理
// 这里只返回分享参数
resolve({
title: options.title || '',
path: options.path || '/pages/index/index',
imageUrl: options.imageUrl || ''
});
// #endif
// #ifdef H5
// H5平台分享
if (navigator.share && options.type === 0) {
// 使用Web Share API
navigator.share({
title: options.title || '',
text: options.summary || '',
url: options.href || window.location.href
}).then(() => {
resolve({ success: true });
}).catch((err) => {
reject(err);
});
} else {
// 复制链接
const input = document.createElement('input');
input.setAttribute('readonly', 'readonly');
input.setAttribute('value', options.href || window.location.href);
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
uni.showToast({
title: '链接已复制,请粘贴发送给好友',
icon: 'none'
});
resolve({ success: true });
}
// #endif
});
}
// 在页面中使用
// 1. 导入分享方法
import { shareContent } from '@/utils/share.js';
// 2. 在页面中定义分享方法
export default {
data() {
return {
shareData: {
title: '分享标题',
summary: '分享摘要',
imageUrl: '/static/logo.png',
href: 'https://example.com'
}
}
},
methods: {
// 点击分享按钮
handleShare() {
shareContent(this.shareData)
.then(res => {
console.log('分享成功', res);
})
.catch(err => {
console.error('分享失败', err);
});
}
},
// #ifdef MP-WEIXIN
// 微信小程序分享
onShareAppMessage() {
return shareContent(this.shareData);
},
// 分享到朋友圈
onShareTimeline() {
return {
title: this.shareData.title,
imageUrl: this.shareData.imageUrl
};
}
// #endif
}
2. 支付功能适配
不同平台的支付功能实现方式不同:
js
// payment.js
/**
* 通用支付方法
* @param {Object} options 支付参数
* @returns {Promise} 支付结果Promise
*/
export function payment(options) {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
// App平台支付
uni.requestPayment({
provider: options.provider || 'alipay', // 支付提供商
orderInfo: options.orderInfo, // 订单数据
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
// #endif
// #ifdef MP-WEIXIN
// 微信小程序支付
uni.requestPayment({
timeStamp: options.timeStamp,
nonceStr: options.nonceStr,
package: options.package,
signType: options.signType,
paySign: options.paySign,
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
// #endif
// #ifdef MP-ALIPAY
// 支付宝小程序支付
uni.requestPayment({
tradeNO: options.tradeNO,
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
// #endif
// #ifdef H5
// H5平台支付
// H5平台通常是跳转到支付页面
if (options.payUrl) {
window.location.href = options.payUrl;
resolve({ success: true });
} else {
reject(new Error('缺少支付链接'));
}
// #endif
});
}
// 在页面中使用
// 1. 导入支付方法
import { payment } from '@/utils/payment.js';
// 2. 在页面中定义支付方法
export default {
methods: {
// 发起支付
handlePay() {
// 先从服务器获取支付参数
uni.request({
url: 'https://api.example.com/pay',
method: 'POST',
data: {
orderId: '123456',
amount: 99.99
},
success: (res) => {
// 根据平台处理支付参数
let payParams = {};
// #ifdef APP-PLUS
payParams = {
provider: 'alipay',
orderInfo: res.data.orderInfo
};
// #endif
// #ifdef MP-WEIXIN
payParams = {
timeStamp: res.data.timeStamp,
nonceStr: res.data.nonceStr,
package: res.data.package,
signType: res.data.signType,
paySign: res.data.paySign
};
// #endif
// #ifdef MP-ALIPAY
payParams = {
tradeNO: res.data.tradeNO
};
// #endif
// #ifdef H5
payParams = {
payUrl: res.data.payUrl
};
// #endif
// 发起支付
payment(payParams)
.then(result => {
console.log('支付成功', result);
uni.showToast({
title: '支付成功',
icon: 'success'
});
// 跳转到订单页面
setTimeout(() => {
uni.navigateTo({
url: '/pages/order/detail?id=123456'
});
}, 1500);
})
.catch(err => {
console.error('支付失败', err);
uni.showToast({
title: '支付失败',
icon: 'none'
});
});
},
fail: (err) => {
console.error('获取支付参数失败', err);
uni.showToast({
title: '获取支付参数失败',
icon: 'none'
});
}
});
}
}
}
3. 推送通知适配
不同平台的推送通知实现方式不同:
js
// push.js
/**
* 初始化推送服务
* @returns {Promise} 初始化结果Promise
*/
export function initPushService() {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
// App平台推送
const push = plus.push;
push.addEventListener('receive', (message) => {
// 处理接收到的推送消息
console.log('收到推送消息:', message);
// 消息处理
handlePushMessage(message);
});
push.getClientInfo({
success: (info) => {
// 获取到客户端推送标识
console.log('推送标识:', info);
// 将推送标识上传到服务器
uploadPushClientId(info.clientid);
resolve(info);
},
fail: (err) => {
console.error('获取推送标识失败:', err);
reject(err);
}
});
// #endif
// #ifdef MP-WEIXIN
// 微信小程序推送
wx.requestSubscribeMessage({
tmplIds: options.tmplIds || [],
success: (res) => {
console.log('订阅消息成功:', res);
resolve(res);
},
fail: (err) => {
console.error('订阅消息失败:', err);
reject(err);
}
});
// #endif
// #ifdef H5
// H5平台推送
// 使用Web Push API
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
// 请求通知权限
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
console.log('通知权限已授予');
// 注册Service Worker
navigator.serviceWorker.register('/sw.js').then((registration) => {
console.log('Service Worker注册成功');
// 订阅推送
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(options.vapidPublicKey)
}).then((subscription) => {
console.log('推送订阅成功:', subscription);
// 将订阅信息发送到服务器
uploadPushSubscription(subscription);
resolve(subscription);
}).catch((err) => {
console.error('推送订阅失败:', err);
reject(err);
});
}).catch((err) => {
console.error('Service Worker注册失败:', err);
reject(err);
});
} else {
console.warn('通知权限被拒绝');
reject(new Error('通知权限被拒绝'));
}
});
} else {
console.warn('浏览器不支持推送通知');
reject(new Error('浏览器不支持推送通知'));
}
// #endif
});
}
/**
* 处理推送消息
* @param {Object} message 推送消息
*/
function handlePushMessage(message) {
// 根据消息类型处理
const messageType = message.type || 'notification';
switch (messageType) {
case 'notification':
// 通知类型消息
showNotification(message);
break;
case 'custom':
// 自定义消息
handleCustomMessage(message);
break;
default:
console.log('未知消息类型:', messageType);
break;
}
}
/**
* 显示通知
* @param {Object} message 消息内容
*/
function showNotification(message) {
// #ifdef APP-PLUS
plus.push.createMessage(
message.content || '',
message.payload || '',
{
title: message.title || '新消息',
icon: '/static/logo.png'
}
);
// #endif
// #ifdef H5
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(message.title || '新消息', {
body: message.content || '',
icon: '/static/logo.png'
});
}
// #endif
}
/**
* 上传推送标识到服务器
* @param {string} clientId 客户端标识
*/
function uploadPushClientId(clientId) {
uni.request({
url: 'https://api.example.com/push/register',
method: 'POST',
data: {
clientId,
platform: uni.getSystemInfoSync().platform,
deviceId: uni.getSystemInfoSync().deviceId || ''
},
success: (res) => {
console.log('推送标识上传成功:', res.data);
},
fail: (err) => {
console.error('推送标识上传失败:', err);
}
});
}
/**
* 上传Web Push订阅信息到服务器
* @param {Object} subscription 订阅信息
*/
function uploadPushSubscription(subscription) {
uni.request({
url: 'https://api.example.com/push/web-register',
method: 'POST',
data: {
subscription: JSON.stringify(subscription),
platform: 'web',
userAgent: navigator.userAgent
},
success: (res) => {
console.log('Web Push订阅信息上传成功:', res.data);
},
fail: (err) => {
console.error('Web Push订阅信息上传失败:', err);
}
});
}
/**
* 处理自定义消息
* @param {Object} message 消息内容
*/
function handleCustomMessage(message) {
// 处理自定义消息
console.log('处理自定义消息:', message);
// 触发全局事件
uni.$emit('custom-push-message', message);
}
/**
* Base64 URL转Uint8Array
* @param {string} base64String Base64字符串
* @returns {Uint8Array} Uint8Array
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// 在页面中使用
// 1. 导入推送服务
import { initPushService } from '@/utils/push.js';
// 2. 在页面中初始化推送服务
export default {
onLoad() {
// 初始化推送服务
initPushService({
// 微信小程序模板ID
tmplIds: ['abc123', 'def456'],
// Web Push VAPID公钥
vapidPublicKey: 'BLVYxYrJwsrAL9...'
}).then(res => {
console.log('推送服务初始化成功:', res);
}).catch(err => {
console.error('推送服务初始化失败:', err);
});
// 监听自定义推送消息
uni.$on('custom-push-message', this.handleCustomPushMessage);
},
onUnload() {
// 移除监听
uni.$off('custom-push-message', this.handleCustomPushMessage);
},
methods: {
handleCustomPushMessage(message) {
console.log('收到自定义推送消息:', message);
// 处理消息
if (message.action === 'openPage') {
uni.navigateTo({
url: message.url
});
}
}
}
}
性能优化
1. 首屏加载优化
不同平台的首屏加载优化策略:
js
// app.vue
<script>
export default {
onLaunch: function() {
// 预加载数据
this.preloadData();
// 预加载页面
this.preloadPages();
},
methods: {
preloadData() {
// #ifdef APP-PLUS
// App平台可以在启动时预加载数据
uni.request({
url: 'https://api.example.com/config',
success: (res) => {
// 缓存全局配置
getApp().globalData.config = res.data;
}
});
// #endif
// #ifdef MP
// 小程序平台可以使用周期性更新
const lastUpdateTime = uni.getStorageSync('lastUpdateTime') || 0;
const now = Date.now();
// 超过1小时才更新
if (now - lastUpdateTime > 3600000) {
uni.request({
url: 'https://api.example.com/config',
success: (res) => {
// 缓存全局配置
getApp().globalData.config = res.data;
// 更新时间
uni.setStorageSync('lastUpdateTime', now);
}
});
} else {
// 使用缓存数据
getApp().globalData.config = uni.getStorageSync('config');
}
// #endif
},
preloadPages() {
// #ifdef APP-PLUS
// App平台可以预加载页面
uni.preloadPage({
url: '/pages/index/index'
});
uni.preloadPage({
url: '/pages/user/user'
});
// #endif
}
}
}
</script>
2. 图片优化
针对不同平台的图片优化:
html
<template>
<view class="container">
<!-- 懒加载图片 -->
<image
class="lazy-image"
:src="imageSrc"
:lazy-load="true"
@load="onImageLoad"
@error="onImageError"
></image>
<!-- 根据平台加载不同尺寸的图片 -->
<image class="responsive-image" :src="responsiveImageSrc"></image>
</view>
</template>
<script>
export default {
data() {
return {
imageSrc: '',
placeholderImage: '/static/placeholder.png',
imageUrl: 'https://example.com/image.jpg',
imageLoaded: false
}
},
computed: {
responsiveImageSrc() {
const systemInfo = uni.getSystemInfoSync();
const pixelRatio = systemInfo.pixelRatio || 2;
// 根据设备像素比选择不同尺寸的图片
return `https://example.com/image_${pixelRatio}x.jpg`;
}
},
onLoad() {
// 先显示占位图
this.imageSrc = this.placeholderImage;
// 预加载实际图片
this.preloadImage(this.imageUrl);
},
methods: {
preloadImage(url) {
// #ifdef APP-PLUS || H5
// App和H5平台可以使用Image对象预加载
const image = new Image();
image.onload = () => {
// 图片加载完成后替换
this.imageSrc = url;
this.imageLoaded = true;
};
image.onerror = () => {
console.error('图片加载失败:', url);
// 保持占位图
};
image.src = url;
// #endif
// #ifdef MP
// 小程序平台直接设置src
this.imageSrc = url;
// #endif
},
onImageLoad(e) {
console.log('图片加载成功:', e);
this.imageLoaded = true;
},
onImageError(e) {
console.error('图片加载失败:', e);
// 显示占位图
this.imageSrc = this.placeholderImage;
}
}
}
</script>
<style>
.lazy-image {
width: 100%;
height: 200px;
background-color: #f5f5f5;
}
.responsive-image {
width: 100%;
height: 300px;
}
</style>
3. 列表优化
针对不同平台的长列表优化:
html
<template>
<view class="container">
<!-- #ifdef APP-PLUS || H5 -->
<!-- App和H5平台使用虚拟列表 -->
<virtual-list
:list-data="listData"
:item-height="100"
:visible-count="10"
@load-more="loadMore"
>
<template v-slot:item="{ item, index }">
<view class="list-item" :key="index">
<text class="item-title">{{ item.title }}</text>
<text class="item-desc">{{ item.description }}</text>
</view>
</template>
</virtual-list>
<!-- #endif -->
<!-- #ifdef MP -->
<!-- 小程序平台使用scroll-view -->
<scroll-view
class="scroll-list"
scroll-y
@scrolltolower="loadMore"
:enable-back-to-top="true"
:scroll-top="scrollTop"
>
<view
class="list-item"
v-for="(item, index) in listData"
:key="index"
>
<text class="item-title">{{ item.title }}</text>
<text class="item-desc">{{ item.description }}</text>
</view>
<!-- 加载更多 -->
<view class="loading" v-if="loading">
<text>加载中...</text>
</view>
<!-- 没有更多数据 -->
<view class="no-more" v-if="finished">
<text>没有更多数据了</text>
</view>
</scroll-view>
<!-- 返回顶部按钮 -->
<view class="back-to-top" v-if="showBackToTop" @click="scrollToTop">
<text class="iconfont icon-top"></text>
</view>
<!-- #endif -->
</view>
</template>
<script>
// 导入虚拟列表组件(仅App和H5平台)
// #ifdef APP-PLUS || H5
import VirtualList from '@/components/virtual-list.vue';
// #endif
export default {
components: {
// #ifdef APP-PLUS || H5
VirtualList
// #endif
},
data() {
return {
listData: [],
loading: false,
finished: false,
page: 1,
limit: 20,
scrollTop: 0,
showBackToTop: false
}
},
onLoad() {
this.loadData();
},
onPageScroll(e) {
// 显示/隐藏返回顶部按钮
this.showBackToTop = e.scrollTop > 300;
},
methods: {
// 加载数据
loadData() {
if (this.loading || this.finished) {
return;
}
this.loading = true;
// 模拟请求
setTimeout(() => {
const newData = Array.from({ length: this.limit }, (_, i) => {
const id = (this.page - 1) * this.limit + i + 1;
return {
id,
title: `标题 ${id}`,
description: `这是第 ${id} 条数据的描述信息`
};
});
this.listData = [...this.listData, ...newData];
this.loading = false;
this.page++;
// 模拟数据加载完毕
if (this.page > 5) {
this.finished = true;
}
}, 500);
},
// 加载更多
loadMore() {
this.loadData();
},
// 滚动到顶部
scrollToTop() {
// #ifdef MP
this.scrollTop = 0;
// #endif
// #ifdef APP-PLUS || H5
uni.pageScrollTo({
scrollTop: 0,
duration: 300
});
// #endif
}
}
}
</script>
<style>
.container {
position: relative;
height: 100vh;
}
.scroll-list {
height: 100%;
}
.list-item {
padding: 20rpx;
border-bottom: 1px solid #eee;
background-color: #fff;
}
.item-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.item-desc {
font-size: 28rpx;
color: #666;
}
.loading, .no-more {
padding: 20rpx;
text-align: center;
color: #999;
font-size: 24rpx;
}
.back-to-top {
position: fixed;
right: 30rpx;
bottom: 100rpx;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
</style>
调试与测试
1. 跨平台调试
针对不同平台的调试方法:
js
// debug.js
/**
* 跨平台日志工具
*/
class Logger {
constructor(options = {}) {
this.enabled = options.enabled !== false;
this.level = options.level || 'info';
this.tag = options.tag || 'App';
// 日志级别
this.levels = {
debug: 0,
info: 1,
warn: 2,
error: 3
};
}
/**
* 判断是否应该输出日志
* @param {string} level 日志级别
* @returns {boolean} 是否输出
*/
shouldLog(level) {
return this.enabled && this.levels[level] >= this.levels[this.level];
}
/**
* 格式化日志
* @param {string} level 日志级别
* @param {Array} args 日志参数
* @returns {Array} 格式化后的参数
*/
formatLog(level, args) {
const time = new Date().toISOString();
const prefix = `[${this.tag}] [${level.toUpperCase()}] [${time}]`;
return [prefix, ...args];
}
/**
* 输出调试日志
*/
debug(...args) {
if (this.shouldLog('debug')) {
console.log(...this.formatLog('debug', args));
}
}
/**
* 输出信息日志
*/
info(...args) {
if (this.shouldLog('info')) {
console.info(...this.formatLog('info', args));
}
}
/**
* 输出警告日志
*/
warn(...args) {
if (this.shouldLog('warn')) {
console.warn(...this.formatLog('warn', args));
}
}
/**
* 输出错误日志
*/
error(...args) {
if (this.shouldLog('error')) {
console.error(...this.formatLog('error', args));
}
}
/**
* 输出网络请求日志
* @param {Object} options 请求选项
* @param {Object} response 响应数据
* @param {number} startTime 开始时间
*/
logRequest(options, response, startTime) {
if (!this.shouldLog('debug')) {
return;
}
const endTime = Date.now();
const duration = endTime - startTime;
const logData = {
url: options.url,
method: options.method || 'GET',
data: options.data,
duration: `${duration}ms`,
status: response.statusCode,
response: response.data
};
console.group(`[${this.tag}] [REQUEST] ${options.method || 'GET'} ${options.url}`);
console.log('Request:', {
url: options.url,
method: options.method || 'GET',
data: options.data
});
console.log(`Response: [${response.statusCode}] (${duration}ms)`, response.data);
console.groupEnd();
}
/**
* 输出性能日志
* @param {string} name 性能标记名称
* @param {string} action 动作(start/end)
*/
perf(name, action = 'start') {
if (!this.shouldLog('debug')) {
return;
}
// #ifdef APP-PLUS || H5
if (action === 'start') {
console.time(name);
} else if (action === 'end') {
console.timeEnd(name);
}
// #endif
// #ifdef MP
if (action === 'start') {
this._perfMarks = this._perfMarks || {};
this._perfMarks[name] = Date.now();
} else if (action === 'end') {
if (this._perfMarks && this._perfMarks[name]) {
const duration = Date.now() - this._perfMarks[name];
console.log(`[${this.tag}] [PERF] ${name}: ${duration}ms`);
delete this._perfMarks[name];
}
}
// #endif
}
}
// 创建日志实例
const logger = new Logger({
enabled: process.env.NODE_ENV !== 'production',
level: 'debug',
tag: 'UniApp'
});
export default logger;
2. 跨平台测试
针对不同平台的测试方法:
js
// test-utils.js
/**
* 跨平台测试工具
*/
class TestUtils {
/**
* 获取当前平台
* @returns {string} 平台标识
*/
static getPlatform() {
// #ifdef APP-PLUS
return 'APP';
// #endif
// #ifdef H5
return 'H5';
// #endif
// #ifdef MP-WEIXIN
return 'MP-WEIXIN';
// #endif
// #ifdef MP-ALIPAY
return 'MP-ALIPAY';
// #endif
// #ifdef MP-BAIDU
return 'MP-BAIDU';
// #endif
// #ifdef MP-TOUTIAO
return 'MP-TOUTIAO';
// #endif
// #ifdef MP-QQ
return 'MP-QQ';
// #endif
return 'UNKNOWN';
}
/**
* 判断是否是App平台
* @returns {boolean} 是否是App平台
*/
static isApp() {
// #ifdef APP-PLUS
return true;
// #endif
return false;
}
/**
* 判断是否是H5平台
* @returns {boolean} 是否是H5平台
*/
static isH5() {
// #ifdef H5
return true;
// #endif
return false;
}
/**
* 判断是否是小程序平台
* @returns {boolean} 是否是小程序平台
*/
static isMp() {
// #ifdef MP
return true;
// #endif
return false;
}
/**
* 模拟API调用
* @param {Function} api 要调用的API
* @param {Object} params 参数
* @param {Object} mockResult 模拟结果
* @returns {Promise} Promise对象
*/
static mockApiCall(api, params, mockResult) {
return new Promise((resolve, reject) => {
// 开发环境使用模拟数据
if (process.env.NODE_ENV === 'development') {
setTimeout(() => {
if (mockResult.success) {
resolve(mockResult.data);
} else {
reject(mockResult.error);
}
}, mockResult.delay || 300);
} else {
// 生产环境实际调用
api(params)
.then(resolve)
.catch(reject);
}
});
}
/**
* 检查API是否可用
* @param {string} apiName API名称
* @returns {boolean} 是否可用
*/
static isApiAvailable(apiName) {
if (!uni[apiName]) {
return false;
}
// 特殊API检查
if (apiName === 'share' && !this.isApp()) {
return false;
}
if (apiName === 'requestPayment' && this.isH5()) {
return false;
}
return true;
}
/**
* 安全调用API
* @param {string} apiName API名称
* @param {Object} params 参数
* @param {Function} fallback 降级函数
* @returns {Promise} Promise对象
*/
static safeApiCall(apiName, params, fallback) {
return new Promise((resolve, reject) => {
if (this.isApiAvailable(apiName)) {
uni[apiName]({
...params,
success: resolve,
fail: reject
});
} else if (typeof fallback === 'function') {
try {
const result = fallback(params);
resolve(result);
} catch (err) {
reject(err);
}
} else {
reject(new Error(`API ${apiName} 不可用`));
}
});
}
}
export default TestUtils;
总结
uni-app的跨平台适配是一个系统性工程,需要从多个方面进行考虑:
- 条件编译:使用条件编译处理平台差异,但要避免过度使用
- 样式适配:使用rpx单位、安全区域适配、暗黑模式等技术实现一致的视觉效果
- 功能适配:针对导航栏、底部安全区域、键盘遮挡等常见问题提供解决方案
- 平台特有功能:为分享、支付、推送等平台特有功能提供统一的API封装
- 性能优化:针对不同平台的性能特点,采用相应的优化策略
- 调试与测试:提供跨平台的调试和测试工具,确保应用在各平台的稳定性
通过合理的架构设计和适配策略,可以在保持代码统一性的同时,充分发挥各平台的特性,提供良好的用户体验。