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: 120rpx; /* 為底部導航列預留空間 */
}

.footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  background-color: #ffffff;
  border-top: 1px solid #eeeeee;
  height: 100rpx;
}

.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;
  padding: 10rpx 0;
}

.icon {
  font-size: 44rpx;
  color: #999999;
  margin-bottom: 8rpx;
}

.text {
  font-size: 20rpx;
  color: #999999;
}

.text.active {
  color: #007AFF;
}

.tab-item .icon .iconfont.active {
  color: #007AFF;
}
</style>

3. 鍵盤遮擋處理

在表單輸入時,需要處理鍵盤遮擋問題:

html
<template>
  <view class="page" :style="{ paddingBottom: keyboardHeight + 'px' }">
    <view class="form">
      <view class="form-item">
        <input 
          type="text" 
          placeholder="請輸入使用者名稱" 
          v-model="username"
          @focus="onInputFocus"
          @blur="onInputBlur"
        />
      </view>
      <view class="form-item">
        <input 
          type="password" 
          placeholder="請輸入密碼" 
          v-model="password"
          @focus="onInputFocus"
          @blur="onInputBlur"
        />
      </view>
      <button class="submit-btn" @click="submit">登入</button>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      username: '',
      password: '',
      keyboardHeight: 0
    }
  },
  onLoad() {
    // 監聽鍵盤高度變化
    uni.onKeyboardHeightChange((res) => {
      this.keyboardHeight = res.height;
    });
  },
  methods: {
    onInputFocus() {
      // 輸入框獲得焦點時的處理
    },
    onInputBlur() {
      // 輸入框失去焦點時的處理
    },
    submit() {
      // 提交表單
      console.log('使用者名稱:', this.username);
      console.log('密碼:', this.password);
    }
  }
}
</script>

<style>
.page {
  min-height: 100vh;
  padding: 30rpx;
  transition: padding-bottom 0.3s;
}

.form {
  max-width: 600rpx;
  margin: 0 auto;
}

.form-item {
  margin-bottom: 30rpx;
}

.form-item input {
  width: 100%;
  height: 80rpx;
  padding: 0 20rpx;
  border: 1px solid #dddddd;
  border-radius: 8rpx;
  font-size: 28rpx;
}

.submit-btn {
  width: 100%;
  height: 80rpx;
  background-color: #007AFF;
  color: #ffffff;
  border: none;
  border-radius: 8rpx;
  font-size: 32rpx;
  margin-top: 40rpx;
}
</style>

平台特有功能

1. 分享功能

不同平台的分享實現方式不同:

js
// share.js

/**
 * 統一分享功能
 */
class ShareManager {
  /**
   * 分享內容
   * @param {Object} options 分享選項
   */
  static share(options) {
    const { title, content, imageUrl, path } = options;
    
    // #ifdef APP-PLUS
    // App平台使用原生分享
    uni.share({
      provider: 'weixin',
      scene: 'WXSceneSession',
      type: 0,
      href: path,
      title: title,
      summary: content,
      imageUrl: imageUrl,
      success: (res) => {
        console.log('分享成功');
      },
      fail: (err) => {
        console.error('分享失敗:', err);
      }
    });
    // #endif
    
    // #ifdef MP-WEIXIN
    // 微信小程式使用轉發
    wx.showShareMenu({
      withShareTicket: true,
      success: (res) => {
        console.log('設定分享選單成功');
      }
    });
    // #endif
    
    // #ifdef H5
    // H5平台使用Web Share API或自訂分享
    if (navigator.share) {
      navigator.share({
        title: title,
        text: content,
        url: window.location.href
      }).then(() => {
        console.log('分享成功');
      }).catch((err) => {
        console.error('分享失敗:', err);
      });
    } else {
      // 降級到自訂分享
      this.customShare(options);
    }
    // #endif
  }
  
  /**
   * 自訂分享(H5降級方案)
   * @param {Object} options 分享選項
   */
  static customShare(options) {
    // 顯示分享選單
    uni.showActionSheet({
      itemList: ['複製連結', '分享到微信', '分享到微博'],
      success: (res) => {
        switch (res.tapIndex) {
          case 0:
            this.copyToClipboard(window.location.href);
            break;
          case 1:
            this.shareToWeChat(options);
            break;
          case 2:
            this.shareToWeibo(options);
            break;
        }
      }
    });
  }
  
  /**
   * 複製到剪貼簿
   * @param {string} text 要複製的文字
   */
  static copyToClipboard(text) {
    // #ifdef H5
    if (navigator.clipboard) {
      navigator.clipboard.writeText(text).then(() => {
        uni.showToast({
          title: '連結已複製',
          icon: 'success'
        });
      });
    } else {
      // 降級方案
      const textArea = document.createElement('textarea');
      textArea.value = text;
      document.body.appendChild(textArea);
      textArea.select();
      document.execCommand('copy');
      document.body.removeChild(textArea);
      uni.showToast({
        title: '連結已複製',
        icon: 'success'
      });
    }
    // #endif
    
    // #ifdef APP-PLUS || MP
    uni.setClipboardData({
      data: text,
      success: () => {
        uni.showToast({
          title: '連結已複製',
          icon: 'success'
        });
      }
    });
    // #endif
  }
  
  /**
   * 分享到微信(H5)
   * @param {Object} options 分享選項
   */
  static shareToWeChat(options) {
    // 實現微信分享邏輯
    console.log('分享到微信:', options);
  }
  
  /**
   * 分享到微博(H5)
   * @param {Object} options 分享選項
   */
  static shareToWeibo(options) {
    // 實現微博分享邏輯
    const url = `https://service.weibo.com/share/share.php?url=${encodeURIComponent(window.location.href)}&title=${encodeURIComponent(options.title)}&pic=${encodeURIComponent(options.imageUrl)}`;
    window.open(url, '_blank');
  }
}

export default ShareManager;

2. 支付功能

不同平台的支付實現:

js
// payment.js

/**
 * 統一支付功能
 */
class PaymentManager {
  /**
   * 發起支付
   * @param {Object} options 支付選項
   */
  static pay(options) {
    const { provider, orderInfo, amount } = options;
    
    return new Promise((resolve, reject) => {
      // #ifdef APP-PLUS
      // App平台支付
      uni.requestPayment({
        provider: provider, // 'alipay', 'wxpay'
        orderInfo: orderInfo,
        success: (res) => {
          resolve(res);
        },
        fail: (err) => {
          reject(err);
        }
      });
      // #endif
      
      // #ifdef MP-WEIXIN
      // 微信小程式支付
      wx.requestPayment({
        timeStamp: orderInfo.timeStamp,
        nonceStr: orderInfo.nonceStr,
        package: orderInfo.package,
        signType: orderInfo.signType,
        paySign: orderInfo.paySign,
        success: (res) => {
          resolve(res);
        },
        fail: (err) => {
          reject(err);
        }
      });
      // #endif
      
      // #ifdef H5
      // H5平台支付(跳轉到支付頁面)
      if (provider === 'alipay') {
        window.location.href = orderInfo.payUrl;
      } else if (provider === 'wxpay') {
        // 微信H5支付
        this.wxH5Pay(orderInfo);
      } else {
        reject(new Error('不支援的支付方式'));
      }
      // #endif
    });
  }
  
  /**
   * 微信H5支付
   * @param {Object} orderInfo 訂單資訊
   */
  static wxH5Pay(orderInfo) {
    // #ifdef H5
    // 跳轉到微信支付頁面
    window.location.href = orderInfo.mweb_url;
    // #endif
  }
  
  /**
   * 檢查支付結果
   * @param {string} orderId 訂單ID
   */
  static checkPaymentResult(orderId) {
    return new Promise((resolve, reject) => {
      // 呼叫後端API檢查支付結果
      uni.request({
        url: '/api/payment/check',
        method: 'POST',
        data: { orderId },
        success: (res) => {
          if (res.data.success) {
            resolve(res.data);
          } else {
            reject(new Error(res.data.message));
          }
        },
        fail: (err) => {
          reject(err);
        }
      });
    });
  }
}

export default PaymentManager;

3. 推送功能

不同平台的推送實現:

js
// push.js

/**
 * 統一推送功能
 */
class PushManager {
  /**
   * 初始化推送
   */
  static init() {
    // #ifdef APP-PLUS
    // App平台推送初始化
    const main = plus.android.runtimeMainActivity();
    const pkgName = main.getPackageName();
    const uid = main.getApplicationInfo().uid;
    
    // 監聽推送訊息
    plus.push.addEventListener('click', (msg) => {
      console.log('推送訊息點擊:', msg);
      this.handlePushMessage(msg);
    });
    
    plus.push.addEventListener('receive', (msg) => {
      console.log('收到推送訊息:', msg);
      this.handlePushMessage(msg);
    });
    // #endif
    
    // #ifdef MP-WEIXIN
    // 微信小程式訂閱訊息
    this.requestSubscribeMessage();
    // #endif
    
    // #ifdef H5
    // H5平台Web Push
    this.initWebPush();
    // #endif
  }
  
  /**
   * 處理推送訊息
   * @param {Object} msg 推送訊息
   */
  static handlePushMessage(msg) {
    const { title, content, payload } = msg;
    
    // 根據推送內容進行相應處理
    if (payload && payload.type) {
      switch (payload.type) {
        case 'order':
          // 跳轉到訂單頁面
          uni.navigateTo({
            url: `/pages/order/detail?id=${payload.orderId}`
          });
          break;
        case 'message':
          // 跳轉到訊息頁面
          uni.navigateTo({
            url: '/pages/message/index'
          });
          break;
        default:
          // 預設處理
          break;
      }
    }
  }
  
  /**
   * 請求訂閱訊息(微信小程式)
   */
  static requestSubscribeMessage() {
    // #ifdef MP-WEIXIN
    wx.requestSubscribeMessage({
      tmplIds: ['template_id_1', 'template_id_2'],
      success: (res) => {
        console.log('訂閱訊息授權成功:', res);
      },
      fail: (err) => {
        console.error('訂閱訊息授權失敗:', err);
      }
    });
    // #endif
  }
  
  /**
   * 初始化Web Push(H5)
   */
  static initWebPush() {
    // #ifdef H5
    if ('serviceWorker' in navigator && 'PushManager' in window) {
      navigator.serviceWorker.register('/sw.js')
        .then((registration) => {
          console.log('Service Worker註冊成功:', registration);
          return registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: this.urlBase64ToUint8Array('your-vapid-public-key')
          });
        })
        .then((subscription) => {
          console.log('Push訂閱成功:', subscription);
          // 將訂閱資訊傳送到伺服器
          this.sendSubscriptionToServer(subscription);
        })
        .catch((err) => {
          console.error('Push初始化失敗:', err);
        });
    }
    // #endif
  }
  
  /**
   * 將訂閱資訊傳送到伺服器
   * @param {Object} subscription 訂閱資訊
   */
  static sendSubscriptionToServer(subscription) {
    // #ifdef H5
    fetch('/api/push/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(subscription)
    });
    // #endif
  }
  
  /**
   * 轉換VAPID金鑰格式
   * @param {string} base64String base64字串
   */
  static urlBase64ToUint8Array(base64String) {
    // #ifdef H5
    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;
    // #endif
  }
}

export default PushManager;

效能最佳化

1. 平台特定最佳化

不同平台有不同的效能特點,需要針對性最佳化:

js
// performance.js

/**
 * 效能最佳化工具
 */
class PerformanceOptimizer {
  /**
   * 初始化效能最佳化
   */
  static init() {
    // #ifdef APP-PLUS
    // App平台最佳化
    this.optimizeForApp();
    // #endif
    
    // #ifdef H5
    // H5平台最佳化
    this.optimizeForH5();
    // #endif
    
    // #ifdef MP
    // 小程式平台最佳化
    this.optimizeForMiniProgram();
    // #endif
  }
  
  /**
   * App平台最佳化
   */
  static optimizeForApp() {
    // #ifdef APP-PLUS
    // 設定webview最佳化
    const currentWebview = this.$scope.$getAppWebview();
    currentWebview.setStyle({
      hardwareAccelerated: true, // 開啟硬體加速
      scrollIndicator: 'none',   // 隱藏滾動條
      bounce: 'vertical'         // 設定回彈效果
    });
    
    // 預載入下一個頁面
    this.preloadNextPage();
    // #endif
  }
  
  /**
   * H5平台最佳化
   */
  static optimizeForH5() {
    // #ifdef H5
    // 圖片懶載入
    this.enableImageLazyLoading();
    
    // 啟用Service Worker快取
    this.enableServiceWorkerCache();
    
    // 預載入關鍵資源
    this.preloadCriticalResources();
    // #endif
  }
  
  /**
   * 小程式平台最佳化
   */
  static optimizeForMiniProgram() {
    // #ifdef MP
    // 設定分包預載入
    this.enableSubpackagePreload();
    
    // 最佳化setData呼叫
    this.optimizeSetData();
    // #endif
  }
  
  /**
   * 預載入下一個頁面
   */
  static preloadNextPage() {
    // #ifdef APP-PLUS
    uni.preloadPage({
      url: '/pages/next/next'
    });
    // #endif
  }
  
  /**
   * 啟用圖片懶載入
   */
  static enableImageLazyLoading() {
    // #ifdef H5
    if ('IntersectionObserver' in window) {
      const imageObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            img.classList.remove('lazy');
            imageObserver.unobserve(img);
          }
        });
      });
      
      document.querySelectorAll('img[data-src]').forEach(img => {
        imageObserver.observe(img);
      });
    }
    // #endif
  }
  
  /**
   * 啟用Service Worker快取
   */
  static enableServiceWorkerCache() {
    // #ifdef H5
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(registration => {
          console.log('Service Worker註冊成功:', registration);
        })
        .catch(error => {
          console.log('Service Worker註冊失敗:', error);
        });
    }
    // #endif
  }
  
  /**
   * 預載入關鍵資源
   */
  static preloadCriticalResources() {
    // #ifdef H5
    const criticalResources = [
      '/static/css/critical.css',
      '/static/js/critical.js',
      '/static/images/logo.png'
    ];
    
    criticalResources.forEach(resource => {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.href = resource;
      
      if (resource.endsWith('.css')) {
        link.as = 'style';
      } else if (resource.endsWith('.js')) {
        link.as = 'script';
      } else if (resource.match(/\.(png|jpg|jpeg|gif|webp)$/)) {
        link.as = 'image';
      }
      
      document.head.appendChild(link);
    });
    // #endif
  }
  
  /**
   * 啟用分包預載入
   */
  static enableSubpackagePreload() {
    // #ifdef MP
    // 在pages.json中配置preloadRule
    // 這裡只是示例,實際配置在pages.json中
    console.log('分包預載入已在pages.json中配置');
    // #endif
  }
  
  /**
   * 最佳化setData呼叫
   */
  static optimizeSetData() {
    // #ifdef MP
    // 批次更新資料
    let pendingData = {};
    let updateTimer = null;
    
    const batchSetData = (data) => {
      Object.assign(pendingData, data);
      
      if (updateTimer) {
        clearTimeout(updateTimer);
      }
      
      updateTimer = setTimeout(() => {
        this.setData(pendingData);
        pendingData = {};
        updateTimer = null;
      }, 16); // 約60fps
    };
    
    // 替換原生setData方法
    const originalSetData = this.setData;
    this.setData = batchSetData;
    // #endif
  }
}

export default PerformanceOptimizer;

2. 記憶體管理

不同平台的記憶體管理策略:

js
// memory.js

/**
 * 記憶體管理工具
 */
class MemoryManager {
  /**
   * 初始化記憶體管理
   */
  static init() {
    // 監聽記憶體警告
    this.watchMemoryWarning();
    
    // 設定自動清理
    this.setupAutoCleanup();
  }
  
  /**
   * 監聽記憶體警告
   */
  static watchMemoryWarning() {
    // #ifdef APP-PLUS
    // App平台記憶體警告
    plus.globalEvent.addEventListener('newintent', () => {
      this.cleanup();
    });
    // #endif
    
    // #ifdef MP
    // 小程式記憶體警告
    wx.onMemoryWarning(() => {
      console.log('記憶體不足警告');
      this.cleanup();
    });
    // #endif
    
    // #ifdef H5
    // H5平台記憶體監控
    if ('memory' in performance) {
      setInterval(() => {
        const memory = performance.memory;
        const usedPercent = memory.usedJSHeapSize / memory.totalJSHeapSize;
        
        if (usedPercent > 0.8) {
          console.log('記憶體使用率過高:', usedPercent);
          this.cleanup();
        }
      }, 10000);
    }
    // #endif
  }
  
  /**
   * 設定自動清理
   */
  static setupAutoCleanup() {
    // 頁面隱藏時清理
    uni.onAppHide(() => {
      this.cleanup();
    });
    
    // 定期清理
    setInterval(() => {
      this.cleanup();
    }, 300000); // 5分鐘清理一次
  }
  
  /**
   * 清理記憶體
   */
  static cleanup() {
    // 清理圖片快取
    this.clearImageCache();
    
    // 清理資料快取
    this.clearDataCache();
    
    // 清理事件監聽器
    this.clearEventListeners();
    
    // 強制垃圾回收(如果支援)
    this.forceGarbageCollection();
  }
  
  /**
   * 清理圖片快取
   */
  static clearImageCache() {
    // #ifdef H5
    // 清理已載入的圖片
    const images = document.querySelectorAll('img');
    images.forEach(img => {
      if (!img.getBoundingClientRect().top < window.innerHeight) {
        img.src = '';
      }
    });
    // #endif
  }
  
  /**
   * 清理資料快取
   */
  static clearDataCache() {
    // 清理過期的本地儲存
    const now = Date.now();
    const keys = uni.getStorageInfoSync().keys;
    
    keys.forEach(key => {
      if (key.startsWith('cache_')) {
        const data = uni.getStorageSync(key);
        if (data && data.expireTime && now > data.expireTime) {
          uni.removeStorageSync(key);
        }
      }
    });
  }
  
  /**
   * 清理事件監聽器
   */
  static clearEventListeners() {
    // 移除不必要的事件監聽器
    // 這裡需要根據具體應用程式進行實現
  }
  
  /**
   * 強制垃圾回收
   */
  static forceGarbageCollection() {
    // #ifdef H5
    if (window.gc) {
      window.gc();
    }
    // #endif
  }
}

export default MemoryManager;

除錯與測試

1. 跨平台除錯

不同平台的除錯方法:

js
// debug.js

/**
 * 跨平台除錯工具
 */
class DebugManager {
  /**
   * 初始化除錯
   */
  static init() {
    // 設定除錯模式
    this.isDebug = process.env.NODE_ENV === 'development';
    
    // 初始化日誌系統
    this.initLogger();
    
    // 設定錯誤捕獲
    this.setupErrorCapture();
  }
  
  /**
   * 初始化日誌系統
   */
  static initLogger() {
    // #ifdef APP-PLUS
    // App平台日誌
    this.logger = {
      log: (message, ...args) => {
        if (this.isDebug) {
          console.log(`[APP] ${message}`, ...args);
          plus.console.log(`[APP] ${message}`, ...args);
        }
      },
      error: (message, ...args) => {
        console.error(`[APP] ${message}`, ...args);
        plus.console.error(`[APP] ${message}`, ...args);
      }
    };
    // #endif
    
    // #ifdef H5
    // H5平台日誌
    this.logger = {
      log: (message, ...args) => {
        if (this.isDebug) {
          console.log(`[H5] ${message}`, ...args);
        }
      },
      error: (message, ...args) => {
        console.error(`[H5] ${message}`, ...args);
        // 傳送錯誤到監控服務
        this.sendErrorToMonitoring(message, args);
      }
    };
    // #endif
    
    // #ifdef MP
    // 小程式平台日誌
    this.logger = {
      log: (message, ...args) => {
        if (this.isDebug) {
          console.log(`[MP] ${message}`, ...args);
        }
      },
      error: (message, ...args) => {
        console.error(`[MP] ${message}`, ...args);
      }
    };
    // #endif
  }
  
  /**
   * 設定錯誤捕獲
   */
  static setupErrorCapture() {
    // Vue錯誤處理
    Vue.config.errorHandler = (err, vm, info) => {
      this.logger.error('Vue錯誤:', err, info);
    };
    
    // Promise錯誤處理
    window.addEventListener('unhandledrejection', (event) => {
      this.logger.error('未處理的Promise拒絕:', event.reason);
    });
    
    // #ifdef H5
    // H5平台全域錯誤處理
    window.addEventListener('error', (event) => {
      this.logger.error('全域錯誤:', event.error);
    });
    // #endif
  }
  
  /**
   * 傳送錯誤到監控服務
   * @param {string} message 錯誤訊息
   * @param {Array} args 錯誤參數
   */
  static sendErrorToMonitoring(message, args) {
    // #ifdef H5
    // 傳送到錯誤監控服務(如Sentry)
    if (window.Sentry) {
      window.Sentry.captureException(new Error(message));
    }
    // #endif
  }
  
  /**
   * 效能監控
   */
  static performanceMonitor() {
    // #ifdef H5
    // 監控頁面載入效能
    window.addEventListener('load', () => {
      const timing = performance.timing;
      const loadTime = timing.loadEventEnd - timing.navigationStart;
      this.logger.log('頁面載入時間:', loadTime + 'ms');
    });
    // #endif
    
    // #ifdef APP-PLUS
    // App平台效能監控
    const startTime = Date.now();
    plus.globalEvent.addEventListener('plusready', () => {
      const loadTime = Date.now() - startTime;
      this.logger.log('App啟動時間:', loadTime + 'ms');
    });
    // #endif
  }
}

// 建立全域日誌物件
const logger = {
  log: DebugManager.logger?.log || console.log,
  error: DebugManager.logger?.error || console.error,
  warn: DebugManager.logger?.warn || console.warn,
  info: DebugManager.logger?.info || console.info
};

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. 除錯與測試:提供跨平台的除錯和測試工具,確保應用程式在各平台的穩定性

透過合理的架構設計和適配策略,可以在保持程式碼統一性的同時,充分發揮各平台的特性,提供良好的使用者體驗。

一次開發,多端部署 - 讓跨平台開發更簡單