Flutter Tree Shaking 真能"瘦身"嗎?多入口+資源隔離,一站式解決跨平臺審核難題

引言

如果你正在開發(fā)同時面向 Android 和 iOS 的 Flutter 應用,且需要滿足不同的支付需求:

  • Android 支持微信/支付寶等第三方支付
  • iOS 僅允許使用 IAP(內購)

那么你肯定關心這兩個核心問題:

  • iOS 包中絕對不能包含任何第三方支付的代碼或資源
  • Android 包中也不應混入 iOS 的實現(xiàn)。

有幾種方案可以實現(xiàn):

  • 打包時使用shell腳本復制對應平臺代碼和資源,難以維護。
  • 將類似支付代碼在原生實現(xiàn),增加開發(fā)量。
  • 使用 Flutter Tree Shaking能力實現(xiàn),推薦。

本文將通過一個完整可運行的示例工程,為你詳細講解:

  • Tree Shaking 原理及其如何實現(xiàn)代碼"瘦身"
  • 如何使用多入口設計實現(xiàn)平臺代碼的物理隔離
  • 如何按平臺隔離資源文件(圖片、圖標等)
  • 打包后如何一鍵驗證確只包含所需內容

遵循本文方案,你既能通過應用商店審核,又能優(yōu)化應用體積。


一、Tree Shaking:像收拾行李一樣簡單

Tree Shaking 的工作原理類似于出門前收拾行李:

  • 編譯器從入口 main() 開始,沿著 import 鏈找到所有"真正需要"的代碼,打包進最終產物
  • 未被入口引用的代碼,不會被包含在內
  • Flutter 的 release 構建使用 Dart AOT(Ahead-of-Time)編譯,產物通常位于:
    • Android: lib/**/libapp.so
    • iOS: 應用二進制文件中

因此,核心策略非常明確:

  • 入口分叉(兩個 main 文件)
  • 代碼路徑分離(各自 import 專屬平臺目錄 + 共享抽象層)
  • Tree Shaking 只會包含從入口可達的代碼路徑

二、項目結構設計(可直接復用)

關鍵目錄結構如下:

lib/
  main_android.dart        # Android 專屬入口
  main_ios.dart            # iOS 專屬入口
  android/                 # Android 專屬 UI/組件
  ios/                     # iOS 專屬 UI/組件

packages/
  shared/                  # 共享包(純 Dart:抽象、DTO、用例)
    lib/ai_chat_shared.dart
    lib/src/...

assets/
  android/                 # Android 平臺資源(icons/images/...)
  ios/                     # iOS 平臺資源(icons/images/...)
  shared/                  # 共享資源(兩端都會打包)
  current/                 # 構建前復制為"當前平臺"資源

scripts/
  build_android.sh         # 復制資源 + 指定入口 + 構建
  build_ios.sh
  verify_isolation.sh      # 打包后自動驗證內容

入口文件設計(核心原則:各走各的路):

// lib/main_android.dart
import 'package:flutter/material.dart';
import 'android/home_android.dart'; // 只導入Android實現(xiàn)

void main() => runApp(const AndroidApp());

class AndroidApp extends StatelessWidget {
  const AndroidApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI Chat - Android',
      theme: ThemeData(colorSchemeSeed: Colors.green, useMaterial3: true),
      home: const HomeAndroid(), // 使用Android專屬頁面
    );
  }
}
// lib/main_ios.dart
import 'package:flutter/material.dart';
import 'ios/home_ios.dart'; // 只導入iOS實現(xiàn)

void main() => runApp(const IOSApp());

class IOSApp extends StatelessWidget {
  const IOSApp({super.key});
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI Chat - iOS',
      theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
      home: const HomeIOS(), // 使用iOS專屬頁面
    );
  }
}

三、共享包設計:只暴露抽象,隱藏實現(xiàn)

共享包 packages/shared 設計原則:

  • 只導出接口與模型,隱藏實現(xiàn)細節(jié)
  • 實現(xiàn)代碼放在 src 目錄,不直接 export
// packages/shared/lib/ai_chat_shared.dart
library ai_chat_shared;

// 只暴露抽象接口和數據模型
export 'src/models/message.dart';
export 'src/services/chat_service.dart'; // 抽象類,非具體實現(xiàn)
// packages/shared/lib/src/services/chat_service.dart
import '../models/message.dart';

// 抽象服務定義
abstract class ChatService {
  Future<Message> sendMessage(String content);
}

// 示例實現(xiàn)(實際項目中各平臺有自己的實現(xiàn))
class MockChatService implements ChatService {
  @override
  Future<Message> sendMessage(String content) async => Message(
    id: DateTime.now().millisecondsSinceEpoch.toString(),
    content: 'AI: $content',
    timestamp: DateTime.now(),
    isUser: false,
  );
}

這種設計的好處:

  • 共享層不依賴任何平臺 SDK,保持純凈
  • 各平臺在自己的目錄中提供具體實現(xiàn)
  • 通過入口文件注入平臺專屬的實現(xiàn)

四、資源隔離:確保只包含當前平臺資源

pubspec.yaml 中只聲明這兩個目錄:

flutter:
  uses-material-design: true
  assets:
    - assets/current/          # 構建時動態(tài)填充
    - assets/current/icons/
    - assets/current/images/
    - assets/shared/           # 共享資源
    - assets/shared/icons/
    - assets/shared/images/

構建腳本負責資源復制:

#!/bin/bash

echo "=== 構建Android應用 ==="
# 使用根目錄現(xiàn)有 android/ 構建,并將隔離資源復制到 assets/current(避免符號鏈接在打包時失效)
rm -rf assets/current && mkdir -p assets/current && cp -R assets/android/. assets/current/
flutter clean
flutter pub get
flutter build apk --release --target=lib/main_android.dart

echo "Android構建完成!"
echo "APK位置: build/app/outputs/flutter-apk/app-release.apk"

iOS 構建腳本類似,復制 assets/ios/assets/current/。

代碼中統(tǒng)一使用 assets/current/... 路徑加載資源,無需平臺判斷。


五、一鍵驗證:確保真正隔離

運行驗證腳本:

./scripts/verify_isolation.sh

輸出示例(Android構建后):

? 找到 assets/current/ 資源清單條目
? 確認 assets/current/ 實際文件存在
? 檢測到 ANDROID 專屬標識字符串
? 未發(fā)現(xiàn) IOS 專屬標識字符串
? 代碼import檢查通過(無跨平臺引用)
? 原生依賴檢查通過(Gradle配置正確)
?? 隔離驗證完全通過!

驗證原理:

  • 資源檢查:確認只有當前平臺和共享資源被打包
  • 代碼檢查:在編譯產物中搜索平臺專屬標識字符串
  • Import檢查:確保沒有跨平臺import語句
  • 原生依賴檢查:驗證Gradle/Podfile配置正確

六、完整驗證腳本設計

#!/bin/bash
set -e

APP_APK="build/app/outputs/flutter-apk/app-release.apk"
IOS_APP_DIR="build/ios/iphoneos/Runner.app"

echo "=== 驗證資源與代碼隔離 ==="
RESULT_OK=1

if [ -f "$APP_APK" ]; then
  echo "[Android] 檢查 APK: $APP_APK"

  echo "- 資源: AssetManifest.json 中的 current 與 shared"
  if unzip -p "$APP_APK" assets/flutter_assets/AssetManifest.json | grep -Eq 'assets/current/|assets/shared/'; then
    echo "? 發(fā)現(xiàn) assets/current/assets/shared 清單條目"
  else
    echo "? Android 資源清單缺失(隔離失?。?; RESULT_OK=0
  fi

  echo "- 資源: 實際文件列表 (assets/current)"
  if unzip -l "$APP_APK" | grep -q 'assets/flutter_assets/assets/current/'; then
    echo "? 發(fā)現(xiàn) assets/current 實際文件"
  else
    echo "? Android 未發(fā)現(xiàn) assets/current 文件(隔離失?。?; RESULT_OK=0
  fi

  echo "- 代碼: 搜索哨兵字符串 (僅 ANDROID 存在,IOS 不應存在)"
  SNAPSHOT_CANDIDATES=$(unzip -l "$APP_APK" | awk '{print $4}' | grep '^assets/flutter_assets/' | grep -E 'snapshot|kernel|app.dill|vm_snapshot_data|isolate_snapshot_data' || true)
  if [ -z "$SNAPSHOT_CANDIDATES" ]; then
    echo "- 未找到 snapshot 文件,嘗試在 libapp.so 中搜索"
    SO_PATHS=$(unzip -l "$APP_APK" | awk '{print $4}' | grep -E '^lib/.*/libapp.so$' || true)
    if [ -z "$SO_PATHS" ]; then
      echo "? 未找到 libapp.so,無法進行代碼哨兵校驗(隔離失?。?; RESULT_OK=0
    else
      AND_FOUND=0
      IOS_FOUND=0
      for f in $SO_PATHS; do
        if unzip -p "$APP_APK" "$f" | strings | grep -q 'SENTINEL_ONLY_IN_ANDROID_CODE'; then AND_FOUND=1; fi
        if unzip -p "$APP_APK" "$f" | strings | grep -q 'SENTINEL_ONLY_IN_IOS_CODE'; then IOS_FOUND=1; fi
      done
      if [ $AND_FOUND -eq 1 ]; then echo "? ANDROID 哨兵存在"; else echo "? 未發(fā)現(xiàn) ANDROID 哨兵(可能被優(yōu)化,確保在代碼中被引用)"; RESULT_OK=0; fi
      if [ $IOS_FOUND -eq 0 ]; then echo "? 未發(fā)現(xiàn) IOS 哨兵"; else echo "? 發(fā)現(xiàn) IOS 哨兵(隔離失?。?; RESULT_OK=0; fi
    fi
  else
    AND_FOUND=0
    IOS_FOUND=0
    for f in $SNAPSHOT_CANDIDATES; do
      if unzip -p "$APP_APK" "$f" | strings | grep -q 'SENTINEL_ONLY_IN_ANDROID_CODE'; then AND_FOUND=1; fi
      if unzip -p "$APP_APK" "$f" | strings | grep -q 'SENTINEL_ONLY_IN_IOS_CODE'; then IOS_FOUND=1; fi
    done
    if [ $AND_FOUND -eq 1 ]; then echo "? ANDROID 哨兵存在"; else echo "? 未發(fā)現(xiàn) ANDROID 哨兵(可能被優(yōu)化,確保在代碼中被引用)"; RESULT_OK=0; fi
    if [ $IOS_FOUND -eq 0 ]; then echo "? 未發(fā)現(xiàn) IOS 哨兵"; else echo "? 發(fā)現(xiàn) IOS 哨兵(隔離失?。?; RESULT_OK=0; fi
  fi
fi

if [ -d "$IOS_APP_DIR" ]; then
  echo "[iOS] 檢查 APP: $IOS_APP_DIR"

  echo "- 資源: AssetManifest.json 中的 current 與 shared"
  if [ -f "$IOS_APP_DIR/Frameworks/App.framework/flutter_assets/AssetManifest.json" ]; then
    if grep -Eq 'assets/current/|assets/shared/' "$IOS_APP_DIR/Frameworks/App.framework/flutter_assets/AssetManifest.json"; then
      echo "? 發(fā)現(xiàn) assets/current/assets/shared 清單條目"
    else
      echo "? iOS 資源清單缺失(隔離失?。?; RESULT_OK=0
    fi
  fi

  echo "- 資源: 實際文件列表 (assets/current)"
  if find "$IOS_APP_DIR" -path '*/Flutter/flutter_assets/assets/current/*' | grep -q .; then
    echo "? 發(fā)現(xiàn) assets/current 實際文件"
  else
    echo "? iOS 未發(fā)現(xiàn) assets/current 文件(隔離失敗)"; RESULT_OK=0
  fi

  echo "- 代碼: 搜索哨兵字符串 (僅 IOS 存在,ANDROID 不應存在)"
  APP_BIN="$IOS_APP_DIR/Frameworks/App.framework/App"
  if [ -f "$APP_BIN" ]; then
    if strings "$APP_BIN" | grep -q 'SENTINEL_ONLY_IN_IOS_CODE'; then echo "? IOS 哨兵存在"; else echo "? 未發(fā)現(xiàn) IOS 哨兵(可能被優(yōu)化,確保在代碼中被引用)"; RESULT_OK=0; fi
    if strings "$APP_BIN" | grep -q 'SENTINEL_ONLY_IN_ANDROID_CODE'; then echo "? 發(fā)現(xiàn) ANDROID 哨兵(隔離失敗)"; RESULT_OK=0; else echo "? 未發(fā)現(xiàn) ANDROID 哨兵"; fi
  else
    echo "? 未找到 iOS App 二進制,無法進行代碼哨兵校驗(隔離失敗)"; RESULT_OK=0
  fi
fi

if [ $RESULT_OK -eq 1 ]; then
  echo "? 隔離驗證通過"
  echo "=== 驗證結束 ==="
  exit 0
else
  echo "? 隔離驗證失?。ㄔ斠娚戏?? 提示項)"
  echo "=== 驗證結束 ==="
  exit 1
fi

每次構建后,只需查看腳本最后輸出:"? 隔離驗證通過" 即可確認這次打包是干凈的。


總結

通過本文介紹的多入口+資源隔離+自動驗證方案,你可以徹底解決Flutter跨平臺應用中的代碼和資源隔離問題。這個方案不僅適用于支付場景,任何需要平臺特定實現(xiàn)的場景都可以借鑒這種方法。

最重要的是,通過自動化驗證腳本,你可以在每次構建后快速確認隔離是否成功,讓審核問題無所遁形,讓應用體積得到優(yōu)化。

這個方案我覺得不完美的地方在于資源仍然需要腳本復制這樣極其難維護,如果你有好的復制資源的方案可在評論區(qū)留言,謝謝。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容