Skip to content

平台适配

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-PLUSApp
APP-PLUS-NVUEApp nvue
H5H5
MP-WEIXIN微信小程序
MP-ALIPAY支付宝小程序
MP-BAIDU百度小程序
MP-TOUTIAO字节跳动小程序
MP-QQQQ小程序
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的跨平台适配是一个系统性工程,需要从多个方面进行考虑:

  1. 条件编译:使用条件编译处理平台差异,但要避免过度使用
  2. 样式适配:使用rpx单位、安全区域适配、暗黑模式等技术实现一致的视觉效果
  3. 功能适配:针对导航栏、底部安全区域、键盘遮挡等常见问题提供解决方案
  4. 平台特有功能:为分享、支付、推送等平台特有功能提供统一的API封装
  5. 性能优化:针对不同平台的性能特点,采用相应的优化策略
  6. 调试与测试:提供跨平台的调试和测试工具,确保应用在各平台的稳定性

通过合理的架构设计和适配策略,可以在保持代码统一性的同时,充分发挥各平台的特性,提供良好的用户体验。

一次开发,多端部署 - 让跨平台开发更简单