普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月15日首页

Flutter 在 iOS 26 模拟器跑不起来?其实很简单

2025年10月15日 09:46

在之前的《Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持》我们提到,在 iOS 26 上为了更好的 Debug 体验,Flutter 在将开发和调试场景切换到了 devicectl + lldb ,从而支持 JIT 运行和 hotload,不过暂时这部分还在 master 没有 3.35 版本。

上述说的这个调整主要影响真机 Debug ,不会影响 Release 和模拟器。

所以 3.35 版本虽然也能在 iOS 26 上进行 Debug 开发,但是在 Xcode 26 的真机上的体验会相对较差,比如 timeout 和耗时是比较常见的情况。

但是最近的一些开发者里发现,它们在 iOS 26 模拟器上也“随机”出现无法运行的情况,运行时会出现 Unable to find a destination matching the provided destination specifie 这样的提示,而在之前的 iOS 18.6 模拟器又运行良好:

Uncategorized (Xcode): Unable to find a destination matching the provided destination specifier:
                { id:6B4F9D28-C76C-4146-9527-E844395B4434 }

        Available destinations for the "Runner" scheme:
                { platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006020-000221002EE8C01E, name:My Mac }
                { platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }
                { platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device }

这是 iOS 26 模拟器和 Flutter 的适配问题吗?其实问题确实是适配导致,但是却不是 Flutter 的问题,而是一些插件和模拟器之间的适配问题,实际上问题是:

用的插件不支持 “ARM 模拟器”,而你默认使用的 iOS 26 模拟器只支持 ARM 。

而解决问题的方式也很简单,只需在 Mac 上安装 Rosetta ,然后从 Xcode 中移除 iOS 26 平台,然后运行以下命令:

xcodebuild -downloadPlatform iOS -architectureVariant universal

重新下载的会是具有通用架构支持的 iOS 26,而不仅仅是基于 Apple 的 ARM 架构默认配置:

所以,解决方案是强制 Xcode 下载 iOS 26 模拟器的“通用”版本,而不是默认的“Apple Silicon”,所以你首先要通过 Xcode -> Settings -> Components -> iOS 26.0 info symbol 确定你的模拟器架构:

删除后重新下载“通用”模拟器,通过 xcodebuild -downloadPlatform iOS -architectureVariant universal 之后,就可以看到通用的 iOS 26 模拟器组件以及 Rosetta 模拟器:

当然,Rosetta 只能说是一个临时的解决方式,核心还是要看哪些插件仍然无法运行 ARM ,所以对于这个问题,更建议的是:

可以创建一个新的 Flutter 项目,然后逐个现在添加插件,看看哪些插件无法在 iOS 26 模拟器上运行,从而找出哪个插件配置错误,因为有可能只是老旧插件 ARCHS 配置错误,它不一定真的就不支持 arm64

所以这次的问题核心并不是 Flutter 的兼容问题,这也是为什么有的人发现,换了个电脑居然有可以跑的原因,主要是升级 Xcode16 之后模拟器重新安装后默认只支持 ARM 架构,如果你的插件之前配置或者设置并没有完全兼容,那么就会让问题暴露出来。

所以,这只是升级 iOS26 下的微小插曲,后面有时间再介绍更大的坑。

参考资

github.com/flutter/flu…

昨天以前首页

Flutter 插件开发实战:桥接原生 SDK

作者 路很长OoO
2025年10月11日 20:12

Flutter 插件开发实战:桥接原生 SDK

Flutter 的强大之处在于其跨平台能力,而 Flutter 插件(Plugin) 则是实现与平台特定功能(如您的广告 SDK)通信的桥梁。

本文将一步步通过了解原理,配置环境,创建项目,指导您完成一个基础 Flutter 插件的创建、原生代码的集成,最终实现 Flutter 层对底层原生 SDK 的调用。


一、Flutter 插件开发核心原理

Flutter 插件是 Dart 代码和平台特定原生代码的结合体。它们之间通过 Platform Channel (平台通道) 进行通信,主要涉及三个关键概念:

概念 作用
MethodChannel 负责 Dart 层与原生层之间的 方法调用数据传输
Dart 层 插件的公共接口,供 Flutter 应用调用。它通过 MethodChannel 将方法调用发送给原生层。
原生层 (Android/iOS/HarmonyOS) 接收 Dart 发来的调用,执行原生 SDK 的功能(如初始化、加载广告),并将结果通过 MethodChannel 返回给 Dart 层。

二、开发环境准备

涉及到的平台有Android、IOS、Harmony,会涉及到Flutter 环境的切换,所以安装FVM进行项目Flutter环境版本管理。下面是对版本管理工具 FVM 的安装、常用命令,以及项目启动、依赖构建和在各平台 IDE 中打开的全部流程。


1. FVM (Flutter 版本管理) 环境安装

FVM (Flutter Version Management) 是一款强大的工具,用于管理和切换不同 Flutter 项目所需的 SDK 版本。安装如下,可以下载一个flutter_sdk 并在系统环境变量Path中配置dart的bin路径,通过dart pub global activate fvm 进行下载安装fvm,或者选择Chocolatey。

windows: 在系统环境变量中配置dart_sdk环境。
mac: 在.bash_profile中配置dart_sdk环境。

步骤 操作 说明
1. 基础 Flutter SDK 首先需 下载并安装 基础的 Flutter SDK。 这是 FVM 依赖运行的环境基础。
2. 配置 Dart 环境变量 确保 Dart SDK 的路径已添加到您的系统 Path 环境变量 中。 Dart SDK 随 Flutter SDK 一起提供,完成 Flutter 环境变量配置即可。
3. 安装 FVM 在命令行(CMD 或 PowerShell)中执行 Dart 包全局激活命令: dart pub global activate fvm

安装完FVM之后,会在用户名下生成fvm目录,这里可以通过 fvm config --cache-path D:\...\fvm <换成自己的路径> 设置。并将其路径配置到环境变量中。

用于不同flutter_sdk版本缓存。如下:

执行完 fvm install 3.35.3等之后就会在fvm/versions/下载对应flutter版本。而当前我们系统环境配置的是Google Flutter环境,而鸿蒙版的flutter_sdk是无法通过fvm install 来下载的。我们只需要进入到fvm/versions下面打开命令行,通过git克隆即可。鸿蒙官方文档

git clone -b 3.22.0-ohos https://gitcode.com/openharmony-tpc/flutter_flutter.git custom_3.22.0

mac: 740a0fe34d6245bd8b2d47ec794bbb5.png

windows:

image.png

fvm config --cache-path D:\wangfei\soft\flutter_windows\fvm 操作之后,会将Flutter_SDK每一个版本缓存到此路径下versions里面。作为fvm缓存仓库。这里建议将fvm缓存仓库和项目放在同一磁盘里面例如都在D盘。

image.png

2. FVM 常用操作命令

安装 FVM 后,您可以使用以下命令来管理和设置项目所需的 Flutter SDK 版本。

命令 作用 默认路径(示例)
fvm install <version> 下载指定版本的 Flutter SDK 到 FVM 的本地缓存中。 Windows: C:\Users\用户名\fvm\versions
mac:/Users/用户名/fvm/versions
fvm list 在项目根目录下执行,查看 FVM 缓存中已安装的所有 SDK 版本。 -
fvm use <version> 在项目根目录下执行,使当前项目配置并使用指定的 Flutter SDK 版本。 在项目根目录创建 .fvm 文件夹。

fvm list可以查看当前导入fvm的所有flutter_sdk版本。下面都是通过fvm install version 下载配置的。

image.png

3. 创建 Flutter 插件项目

使用 flutter create 命令并指定类型为 plugin,即可创建一个包含所有平台代码模板的插件项目。 假设您的插件名为 ads_sdk

如果要支持鸿蒙,需要在创建项目的文件夹下面,执行fvm flutter use custom_3.22.0, 让其后续支持鸿蒙环境下执行命令。避免创建失败。

# --template=plugin 表示创建插件项目
fvm flutter create --template=plugin --platforms=android,ios,ohos ads_sdk

选择编译器打开,我使用的是AndroidStudio

image.png

4. 首次运行与构建依赖

在项目环境中,执行以下步骤以准备编译所需的依赖和平台工具。

A. 下载平台编译工具

这是首次切换 Flutter 版本或首次运行项目时,Flutter SDK 为项目下载 运行所需的“平台工具”和“Web SDK” 的正常准备步骤。

在项目根目录下,执行任务 Flutter 命令触发:

fvm flutter run

custom_3.22.0 是鸿蒙端环境

image.pngimage.png

B. 构建平台依赖

在进入原生 IDE 之前,建议先构建一次 Flutter 插件,为各平台生成所需的配置文件和代码。

进入项目 example 目录 下对应的平台文件夹(例如:ads_sdk/example/android),然后执行相应的构建命令:

平台 目标目录 构建命令
Android ads_sdk/example/android fvm flutter build apk --debug--release
iOS ads_sdk/example/ios fvm flutter build ipa --release (通常用于发布)
HarmonyOS (Ohos) ads_sdk/example/ohos fvm flutter build hap --release

image.png

5. 打开与配置各平台项目

在完成上述构建步骤后,即可使用各平台的原生 IDE 打开项目,并进行后续的库依赖配置和开发工作。

平台 IDE 打开路径 关键操作
Android Android Studio ads_sdk/example/android 打开项目后,等待 Gradle 同步完成,然后进行原生库依赖配置。
HarmonyOS DevEco Studio ads_sdk/example/ohos 打开项目,配置原生 SDK 依赖。
iOS Xcode ads_sdk/example/ios/Runner.xcworkspace 务必打开 .xcworkspace 文件,配置原生库依赖。

完成在原生 IDE 中项目的打开和库依赖的配置后,您就可以开始进行 Flutter 插件的原生层开发工作。


因为我们创建的是flutter插件项目,所以各端编译器打开构建依赖完成之后。互相之间的依赖都已完成。直接可以进行开发了。

下图是AndroidStudio打开项目/example/android,依赖同步完成之后的目录。我们主要的开发工作也在ads_sdk这个module中进行。

image.png

下图是DevEco Studio打开项目/example/ohos,依赖同步完成之后的目录。我们主要的开发工作也在ohos.ads_sdk这个module中进行。

image.png

四、Flutter 层 (Dart) API 设计与实现

这次文章基于我们原生各端【Android、IOS、Harmony】广告SDK进行开发一款Flutter插件,其目的是让Flutter项目开发者可以快速的接入我们的广告SDK【广告,聚合】。

明确开发流程:

我们创建的Flutter插件项目,其项目下/lib 是flutter统一调用公共层,而amps/android|ios|ohos 是各个对应平台需要实现调用各端广告SDK的模块。

image.png

[Flutter项目]  //example【相当于媒体项目,这个不用管,一般作为demo运行验证】
   ↓ 
[Flutter插件 SDK层] //amps_sdk/lib
   ↓  
[各端 SDK(aar、framwork、har)] //amps_sdk/android|ios|harmony

Flutter支持-流程图.jpg

1. 定义 MethodChannel

在 Dart 代码中,首先初始化 MethodChannel,通道名称需要保持 Android/iOS/Windows 原生代码中的通道名称一致(通常默认是 ads_sdk)。

默认会在lib下面创建三个文件,ads_sdk.dart、ads_sdk_method_channel.dart、ads_sdk_platform_interface.dart 。提供了一个获取平台版本的案例,我全部删除,在ads_sdk创建一个全局唯一的MethodChannel ,当然MethodChannel 可以创建多个。我这里默认所有的Flutter端调用都通过此通道进行。

// lib/ads_sdk.dart
import 'package:flutter/services.dart';

class AmpsSdk {
  static const MethodChannel channel = MethodChannel('amps_sdk');
}

2. Flutte层设计实现

广告SDK,简单的几个模块,SDK初始化、开屏、插屏、原生、自渲染。所以我们在Flutter端也分为五个模块。

为了和Android以及Harmony各端原生广告SDK调用一致,我们尽可能Flutter调用也统一。如下是Flutter调用样式 AMPSAdSdk().init(sdkConfig, _callBack);。初始化SDK,入参为sdkConfig(sdk)所需要的各种参数,_callBack 初始化成功失败相关回调。

flutter 没有接口,所以通过方法类型参数代替实现回调。其他入参过多,通过构造者模式设计接口。可以查看相关数据文件。

import 'dart:async';
import 'ads_sdk.dart';
import 'data/amps_init_config.dart';
import 'data/common.dart';
class AMPSAdSdk {
  AMPSIInitCallBack?  _callBack;

  static bool testModel = false;
  AMPSAdSdk() {
    AdsSdk.channel.setMethodCallHandler(
          (call) async {
        switch (call.method) {
          case AMPSInitChannelMethod.initSuccess:
            _callBack?.initSuccess?.call();
            break;
          case AMPSInitChannelMethod.initializing:
            _callBack?.initializing?.call();
            break;
          case AMPSInitChannelMethod.alreadyInit:
            _callBack?.alreadyInit?.call();
            break;
          case AMPSInitChannelMethod.initFailed:
            var map = call.arguments as Map<dynamic, dynamic>;
            _callBack?.initFailed?.call(map[AMPSSdkCallBackErrorKey.code],
                map[AMPSSdkCallBackErrorKey.message]);
            break;
        }
      },
    );
  }

  // 发送数据给native
  Future<void> init(AMPSInitConfig sdkConfig,AMPSIInitCallBack callBack) async {
    _callBack = callBack;
    // 使用时
    await AdsSdk.channel.invokeMethod(
      AMPSAdSdkMethodNames.init,
      sdkConfig.toMap(AMPSAdSdk.testModel),
    );
  }
}

typedef InitSuccessCallBack = void Function();
typedef InitializingCallBack = void Function();
typedef AlreadyInitCallBack = void Function();
typedef InitFailedCallBack = void Function(int? code, String? msg);
// 1. 定义回调接口(抽象类)
class AMPSIInitCallBack {
  // 初始化成功的回调
  late final InitSuccessCallBack? initSuccess;

  // 正在初始化的回调
  late final InitializingCallBack? initializing;

  // 已经初始化的回调
  late final  AlreadyInitCallBack? alreadyInit;

  // 初始化失败的回调
  late final  InitFailedCallBack? initFailed;

  AMPSIInitCallBack({this.initSuccess, this.initializing, this.alreadyInit, this.initFailed});
}

入参AMPSInitConfig如下,通过构造者模式构建API,为了大量的数据传递和解析方便我这里采用了Map传递。只需要Flutte端key和各端一一对应即可。

import 'amps_sdk_api_keys.dart';
//UI模式【自动、黑色、浅色】
enum UiModel { uiModelAuto, uiModelDark, uiModelLight }

//坐标系类型
enum CoordinateType {
  wgs84('WGS84'),
  gcj02('GCJ02'),
  baidu('BAIDU');

  final String value;

  const CoordinateType(this.value);

  @override
  String toString() => value;
}

//适龄标记
enum UnderageTag {
  unknown(-1),
  maturity(0),
  underage(1);

  final int value;

  const UnderageTag(this.value);
}

//初始化设置, 国家类型选项
class CountryType {
  static const COUNTRY_TYPE_CHINA_MAINLAND = 1;
  static const COUNTRY_TYPE_OTHER = 0;
}

//支持的货币类型
class CurrencyType {
  static const CURRENCY_TYPE_CNY = "CNY"; //人民币
  static const CURRENCY_TYPE_USD = "USD"; //美元
  static const CURRENCY_TYPE_JPY = "JPY"; //日元
  static const CURRENCY_TYPE_EUR = "EUR"; //欧元
  static const CURRENCY_TYPE_GBP = "GBP"; //英镑
  static const CURRENCY_TYPE_IDR = "IDR"; //印尼盾
  static const CURRENCY_TYPE_MYR = "MYR"; //马来西亚林吉特
  static const CURRENCY_TYPE_PHP = "PHP"; //菲律宾比索
  static const CURRENCY_TYPE_KRW = "THB"; //泰铢
}

/// 记录三方传入的位置信息,用于上报
class AMPSLocation {
  /// 经度
  double? longitude;

  /// 纬度
  double? latitude;

  /// 坐标系类型,对应原代码中的AMPSConstants.CoordinateType
  /// (默认 0:GCJ02   1:WGS84   2:BAIDU,仅支持QM渠道)
  CoordinateType? coordinate;

  /// 时间戳
  int? timeStamp = 0;

  /// 构造函数,支持初始化时设置属性
  AMPSLocation({
    this.longitude,
    this.latitude,
    this.coordinate,
    int? timeStamp,
  }) {
    this.timeStamp = timeStamp ?? 0; // 确保默认值为0
  }

// 转为 Map
  Map<String, dynamic> toJson() {
    return {
      AMPSLocationKey.latitude: latitude,
      AMPSLocationKey.longitude: longitude,
      AMPSLocationKey.timeStamp: timeStamp,
      AMPSLocationKey.coordinate: coordinate?.value
    };
  }
}

// 假设的工具类
class StrUtil {
  static bool isEmpty(String? str) => str == null || str.isEmpty;

  static String replace(String str, String pattern, String replacement) {
    return str.replaceAll(RegExp(pattern), replacement);
  }
}

/// 用户控制类. 重写相关方法设置SDK可用内容
class AMPSCustomController {
  /// 是否可以使用PhoneState权限
  bool isCanUsePhoneState;

  /// 透传OAID
  String OAID;

  /// 是否允许使用个性化推荐
  /// true: 允许 false: 不允许
  bool isSupportPersonalized;

  /// 适龄标记
  /// 取值参考 [UnderageTag]
  UnderageTag getUnderageTag;

  /// userAgent
  String? userAgent;

  /// 是否可以使用传感器
  bool isCanUseSensor;

  /// 是否允许SDK自身获取定位
  bool isLocationEnabled;

  /// 用于记录,三方设置的位置信息
  AMPSLocation? location;

  AMPSCustomController({
    required AMPSCustomControllerParam? param,
  })  : isCanUsePhoneState = param?.isCanUsePhoneState ?? false,
        OAID = param?.OAID ?? "",
        isSupportPersonalized = param?.isSupportPersonalized ?? true,
        getUnderageTag = param?.getUnderageTag ?? UnderageTag.unknown,
        userAgent = param?.userAgent,
        isCanUseSensor = param?.isCanUseSensor ?? true,
        isLocationEnabled = param?.isLocationEnabled ?? true,
        location = param?.location;

// 转为 Map
  Map<String, dynamic> toJson() {
    return {
      AMPSControllerKey.isCanUsePhoneState: isCanUsePhoneState,
      AMPSControllerKey.oaid: OAID,
      AMPSControllerKey.isSupportPersonalized: isSupportPersonalized,
      AMPSControllerKey.getUnderageTag: getUnderageTag.value, // 枚举用名称传递
      AMPSControllerKey.userAgent: userAgent,
      AMPSControllerKey.isCanUseSensor: isCanUseSensor,
      AMPSControllerKey.isLocationEnabled: isLocationEnabled,
      AMPSControllerKey.location: location?.toJson(), // 嵌套对象序列化
    };
  }
}

/// AMPSCustomController 的参数类
class AMPSCustomControllerParam {
  /// 是否可以使用PhoneState权限
  final bool? isCanUsePhoneState;

  /// 透传OAID
  final String? OAID;

  /// 是否允许使用个性化推荐
  final bool? isSupportPersonalized;

  /// 适龄标记
  final UnderageTag? getUnderageTag;

  /// userAgent
  final String? userAgent;

  /// 是否可以使用传感器
  final bool? isCanUseSensor;

  /// 是否允许SDK自身获取定位
  final bool? isLocationEnabled;

  /// 三方设置的位置信息
  final AMPSLocation? location;

  AMPSCustomControllerParam({
    this.isCanUsePhoneState,
    this.OAID,
    this.isSupportPersonalized,
    this.getUnderageTag,
    this.userAgent,
    this.isCanUseSensor,
    this.isLocationEnabled,
    this.location,
  });
}


// AMPSInitConfig类,用于表示初始化配置
class AMPSInitConfig {
  // 媒体的账户ID
  String appId;

  // 日志模式
  final bool _isDebugSetting;
  final bool _isUseHttps;

  // 是否测试广告位(是否计价)
  final bool isTestAd;

  // 添加支持的现金类型
  final String currency;

  // 国家
  final int countryCN;

  final String appName;
  final UiModel uiModel;
  final bool adapterStatusBarHeight;
  final String userId;
  final String? province;
  final String? city;
  final String? region;

  // 聚合模式下,提前初始化的第三方广告渠道平台
  final List<String>? adapterNames;

  // 聚合模式下,传递第三方广告渠道平台初始化参数
  final Map<String, Map<String, dynamic>> extensionParam;

  final Map<String, dynamic> optionFields;

  final AMPSCustomController adController;
  final bool isMediation;
  static bool isMediationStatic = false;

  void a7bc8pp9i7d(String a5) {
    appId = a5;
  }

  // 构造函数,接收Builder对象并进行初始化
  AMPSInitConfig(AMPSBuilder builder)
      : appId = builder.appId,
        appName = builder.appName,
        _isDebugSetting = builder.isDebugSetting,
        _isUseHttps = builder.isUseHttps,
        userId = builder.userId,
        optionFields = builder.optionFields,
        currency = builder.currency,
        countryCN = builder.countryCN,
        isTestAd = builder.isTestAd,
        adController = builder.adController,
        uiModel = builder.uiModel,
        adapterStatusBarHeight = builder.adapter,
        province = builder.province,
        city = builder.city,
        region = builder.region,
        adapterNames = builder.adapterNames,
        extensionParam = builder.extensionParam,
        isMediation = builder.isMediation;

  // 转为 Map(用于JSON序列化)
  Map<String, dynamic> toMap(bool testModel) {
    return {
      // 基础类型直接传递
      AMPSInitConfigKey.testModel: testModel,
      AMPSInitConfigKey.appId: appId,
      AMPSInitConfigKey.isDebugSetting: _isDebugSetting,
      AMPSInitConfigKey.isUseHttps: _isUseHttps,
      AMPSInitConfigKey.isTestAd: isTestAd,
      AMPSInitConfigKey.currency: currency,
      AMPSInitConfigKey.countryCN: countryCN,
      AMPSInitConfigKey.appName: appName,
      AMPSInitConfigKey.userId: userId,
      AMPSInitConfigKey.province: province,
      AMPSInitConfigKey.adapterStatusBarHeight: adapterStatusBarHeight,
      AMPSInitConfigKey.city: city,
      AMPSInitConfigKey.region: region,
      AMPSInitConfigKey.isMediation: isMediation,
      // 枚举类型:用名称或值传递
      AMPSInitConfigKey.uiModel: uiModel.name, // 假设 UiModel 是枚举
      // 列表类型
      AMPSInitConfigKey.adapterNames: adapterNames,
      // Map 转为 Map(Flutter 中 Map 可直接序列化)
      AMPSInitConfigKey.extensionParam: extensionParam,
      AMPSInitConfigKey.optionFields: optionFields,
      // 嵌套对象:通过 toJson 转换
      AMPSInitConfigKey.adController: adController.toJson(),
    };
  }

// 获取uiModel的方法
  UiModel getUiModel() {
    return uiModel;
  }

// 获取appId的方法
  String getAppId() {
    return appId;
  }

// 获取设置的省份
  String? getProvince() {
    return province;
  }

// 获取设置的城市
  String? getCity() {
    return city;
  }

// 获取设置的地区
  String? getRegion() {
    return region;
  }

// 获取设置的第三方平台参数
  Map<String, Map<String, dynamic>> getExtensionParams() {
    return extensionParam;
  }

  List<String>? getAdapterNames() {
    return adapterNames;
  }

// 获取设置的某个第三方平台参数
  Map<String, dynamic> getExtensionParamItems(String key) {
    if (extensionParam.containsKey(key)) {
      return extensionParam[key] ?? <String,dynamic>{};
    }
    return <String,dynamic>{};
  }

// 获取appName的方法
  String getAppName() {
    return appName;
  }

// 获取isDebugSetting的方法
  bool isDebugSetting() {
    return _isDebugSetting;
  }

// 获取isUseHttps的方法
  bool isUseHttps() {
    return _isUseHttps;
  }

// 获取userId的方法
  String getUserId() {
    return userId;
  }

// 获取用户设置的userAgent
  String? getUserAgent() {
    return adController.userAgent;
  }

// 禁用奔溃日志收集,默认否【默认收集日志】
  bool disableCrashCollect() {
    if (optionFields.containsKey(OptionFieldKey.crashCollectSwitch)) {
      final disableCrashCollect =
      optionFields[OptionFieldKey.crashCollectSwitch];
      if (disableCrashCollect is bool) {
        return disableCrashCollect;
      }
    }
    return false;
  }

  String getLightColor() {
    if (optionFields.containsKey(OptionFieldKey.colorLight)) {
      final lightColor = optionFields[OptionFieldKey.colorLight];
      if (lightColor is String) {
        return lightColor;
      }
    }
    return "";
  }

  String getDarkColor() {
    if (optionFields.containsKey(OptionFieldKey.colorDark)) {
      final darkColor = optionFields[OptionFieldKey.colorDark];
      if (darkColor is String) {
        return darkColor;
      }
    }
    return "";
  }

//用于提供获取用户是否统一SDK自身定位。
  bool isLocationEnabled() {
    return adController.isLocationEnabled;
  }

//用于提供获取用户是否统一SDK自身定位。
  AMPSLocation? getUserLocation() {
    return adController.location;
  }

// 获取optionFields的方法
  Map<String, dynamic> getOptionFields() {
    return optionFields;
  }

// 获取currency的方法
  String getCurrency() {
    return currency;
  }

// 获取countryCN的方法
  int getCountryCN() {
    return countryCN;
  }

// 获取isTestAd的方法
  bool getIsTestAd() {
    return isTestAd;
  }

// 获取自定义OAID的方法
  String getCustomOAID() {
    return adController.OAID;
  }

// 获取是否可以使用电话状态的方法
  bool isCanUsePhoneState() {
    return adController.isCanUsePhoneState;
  }

// 获取是否可以使用传感器
  bool isCanUseSensor() {
    return adController.isCanUseSensor;
  }
}

class AMPSBuilder {
  String appId;
  String appName = "";
  bool isDebugSetting = true;
  bool isUseHttps = false;
  String userId = "";
  Map<String, dynamic> optionFields = {};
  String currency = "";
  int countryCN = CountryType.COUNTRY_TYPE_CHINA_MAINLAND;
  bool isTestAd = false;
  bool adapter = true;
  UiModel uiModel = UiModel.uiModelAuto;
  AMPSCustomController adController = AMPSCustomController(param: null);
  String? province;
  String? city;
  String? region;
  List<String>? adapterNames = [];
  late Map<String, Map<String, dynamic>> extensionParam;
  bool isMediation = false;

  // 构造函数,接收appId和context并进行初始化
  AMPSBuilder(this.appId) {
    extensionParam = <String, Map<String, dynamic>>{};
  }

  // 设置是否启用聚合功能
  AMPSBuilder setIsMediation(bool isMediation) {
    this.isMediation = isMediation;
    return this;
  }

  // 设置省份
  AMPSBuilder setProvince(String pro) {
    province = pro;
    return this;
  }

  // 设置城市
  AMPSBuilder setCity(String city) {
    this.city = city;
    return this;
  }

  // 设置地区
  AMPSBuilder setRegion(String region) {
    this.region = region;
    return this;
  }

  // 设置初始化第三方广告平台
  AMPSBuilder setAdapterNames(List<String> adapters) {
    adapterNames = adapters;
    return this;
  }

  /*
   * 设置某个渠道平台特有配置参数
   * key:渠道参数key,在AMPSConstants.ExtensionParamKey选择
   * param:具体参数集合
   */
  AMPSBuilder setExtensionParamItems(String key, Map<String, dynamic> param) {
    extensionParam[key] = param;
    return this;
  }

  // 设置广告控制器
  AMPSBuilder setAdCustomController(AMPSCustomController controller) {
    adController = controller;
    return this;
  }

  // 设置appName
  AMPSBuilder setAppName(String appName) {
    this.appName = appName;
    return this;
  }

  // 设置调试模式
  AMPSBuilder setDebugSetting(bool debugSetting) {
    isDebugSetting = debugSetting;
    return this;
  }

  // 设置是否使用HTTPS
  AMPSBuilder setUseHttps(bool isUseHttps) {
    this.isUseHttps = isUseHttps;
    return this;
  }

  // 设置用户ID
  AMPSBuilder setUserId(String userId) {
    this.userId = userId;
    return this;
  }

  // 设置选项字段
  AMPSBuilder setOptionFields(Map<String, dynamic> optionFields) {
    this.optionFields = optionFields;
    return this;
  }

  // 设置货币类型
  AMPSBuilder setCurrency(String currency) {
    this.currency = currency;
    return this;
  }

  // 设置国家代码
  AMPSBuilder setCountryCN(int countryCN) {
    this.countryCN = countryCN;
    return this;
  }

  // 设置UI模型
  AMPSBuilder setUiModel(UiModel uiModel) {
    this.uiModel = uiModel;
    return this;
  }

  // 设置是否为测试广告
  AMPSBuilder setIsTestAd(bool isTestAd) {
    this.isTestAd = isTestAd;
    return this;
  }

  // 设置落地页是否适配状态栏高度
  AMPSBuilder setLandStatusBarHeight([bool adapter = true]) {
    this.adapter = adapter;
    return this;
  }

  // 构建AMPSInitConfig对象的方法
  AMPSInitConfig build() {
    return AMPSInitConfig(this);
  }
}

记得导出flutter相关的文件到amps_sdk_export.dart,方便外部调用。我们可以在example中main中尝试调用初始化SDK。

import 'dart:collection';
import 'package:ads_sdk/amps_ad_sdk.dart';
import 'package:flutter/material.dart';
import 'package:ads_sdk/amps_sdk_export.dart';
import 'widgets/blurred_background.dart';
import 'widgets/button_widget.dart';
enum  InitStatus {
  normal,
  initialing,
  alreadyInit,
  success,
  failed
}


void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: 'SplashPage',
      routes: {
        'SplashPage':(context)=>const SplashPage(title: '开屏页面')
      },
    );
  }
}

class SplashPage extends StatefulWidget {
  const SplashPage({super.key, required this.title});

  final String title;

  @override
  State<SplashPage> createState() => _SplashPageState();
}

class _SplashPageState extends State<SplashPage> {
  late AMPSIInitCallBack _callBack;
  InitStatus initStatus = InitStatus.normal;
  late AMPSInitConfig sdkConfig;
  @override
  void initState() {
    //SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
    super.initState();
    _callBack = AMPSIInitCallBack(
        initSuccess: () {
          debugPrint("adk is initSuccess");
          setState(() {
            initStatus = InitStatus.success;
          });
        },
        initializing: () {
          debugPrint("adk is initializing");
        },
        alreadyInit: () {
          debugPrint("adk is alreadyInit");
          setState(() {
            initStatus = InitStatus.alreadyInit;
          });
        },
        initFailed: (code, msg) {
          initStatus = InitStatus.failed;
          debugPrint("adk is initFailed");
          debugPrint("result callBack=code$code;message=$msg");
        });
    HashMap<String, dynamic> optionFields = HashMap();
    optionFields["crashCollectSwitch"] = true;
    optionFields["lightColor"] = "#FFFF0000";
    optionFields["darkColor"] = "#0000FF00";
    HashMap<String, dynamic> ksSdkEx = HashMap();
    ksSdkEx["crashLog"] = true;
    ksSdkEx["ks_sdk_roller"] = "roller_click";
    ksSdkEx["ks_sdk_location"] = "baidu";
    sdkConfig = AMPSBuilder("33545")
        .setCity("北京")
        .setRegion("朝阳区双井")
        .setCurrency(CurrencyType.CURRENCY_TYPE_USD)
        .setCountryCN(CountryType.COUNTRY_TYPE_CHINA_MAINLAND)
        .setDebugSetting(true)
        .setIsMediation(false)
        .setIsTestAd(false)
        .setLandStatusBarHeight(true)
        .setOptionFields(optionFields)
        .setProvince("北京市")
        .setUiModel(UiModel.uiModelDark)
        .setUseHttps(true)
        .setUserId("12345656")
        .setExtensionParamItems("KuaiShouSDK", ksSdkEx)
        .setAppName("Flutter测试APP")
        .setAdapterNames(["ampskuaishouAdapter", "ampsJdSplashAdapter"])
        .setAdCustomController(AMPSCustomController(
        param: AMPSCustomControllerParam(
            isCanUsePhoneState: true,
            isCanUseSensor: true,
            isSupportPersonalized: true,
            isLocationEnabled: true,
            getUnderageTag: UnderageTag.underage,
            userAgent:
            "Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36  ArkWeb/4.1.6.1 Mobile",
            location: AMPSLocation(
                latitude: 39.959836,
                longitude: 116.31985,
                timeStamp: 1113939393,
                coordinate: CoordinateType.baidu)))) //个性化,传感器等外部设置
        .setIsMediation(false)
        .setUiModel(UiModel.uiModelAuto)
        .build();
    AMPSAdSdk.testModel = true;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Stack(
      alignment: AlignmentDirectional.center,
      children: [
        const BlurredBackground(),
        Column(children: [
          const SizedBox(height: 100,width: 0),
          ButtonWidget(
              buttonText: getInitResult(initStatus),
              backgroundColor: getInitColor(initStatus),
              callBack: () {
                AMPSAdSdk().init(sdkConfig, _callBack);
              }
          ),
          const SizedBox(height: 20,width: 0),
          ButtonWidget(
              buttonText: '开屏show案例页面',
              callBack: () {
                // 使用命名路由跳转
                Navigator.pushNamed(context, 'SplashShowPage');
              }
          ),
          const SizedBox(height: 20,width: 0),
          ButtonWidget(
              buttonText: '开屏组件案例页面',
              callBack: () {
                // 使用命名路由跳转
                Navigator.pushNamed(context, 'SplashWidgetPage');
              }
          )
        ],),
      ],
    ));
  }

  String getInitResult(InitStatus status) {
    switch (status) {
      case InitStatus.normal:
        return '点击初始化SDK';
      case InitStatus.initialing:
        return '初始化中';
      case InitStatus.alreadyInit:
        return '已初始化';
      case InitStatus.success:
        return '初始化成功';
      case InitStatus.failed:
        return '初始化失败';
    }
  }

  Color? getInitColor(InitStatus initStatus) {
    switch (initStatus) {
      case InitStatus.normal:
        return Colors.blue;
      case InitStatus.initialing:
        return Colors.grey;
      case InitStatus.alreadyInit:
        return Colors.green;
      case InitStatus.success:
        return Colors.green;
      case InitStatus.failed:
        return Colors.red;
    }
  }
}

五、原生层 (Android) 的实现与桥接

以 Android 为例,我们需要修改 android/src/main/.../AdsSdkPlugin.kt 文件来接收 Dart 层的调用,并调用底层的 Android 原生广告 SDK。 这样我们可以在外部example/lib/main 里面进行调用SDK接口了。

1. 接收 Dart 调用

通过AndroidStudio打开项目/example/android依赖完成之后,ads_sdk module中找到 AdsSdkPlugin 类(不同名称插件项目不一样,我这里是AdsSdkPlugin),这是接收 Dart 调用并进行处理的入口。代码如下,这里可以发现MethodChannel都在AdsSdkPlugin这个类里面,而外部模块调用很多【初始化、开屏、插屏、原生、自渲染】可能几十个上百个,我们是要所有的接收和处理都放到AdsSdkPlugin这一个类里面么?当然可以,但最好不要,代码要求的是分层,高内聚低耦合。所以接下来我们需要设计原生端代码结构。

package com.example.ads_sdk
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

/** AdsSdkPlugin */
class AdsSdkPlugin: FlutterPlugin, MethodCallHandler {
  private lateinit var channel : MethodChannel
  override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
    channel = MethodChannel(flutterPluginBinding.binaryMessenger, "ads_sdk")
    channel.setMethodCallHandler(this)
  }

  override fun onMethodCall(call: MethodCall, result: Result) {
     //全部这里接收各个模块【初始化、开屏、插屏、原生、自渲染】的方法,耦合度过高。 
      case ....初始化:
       初始化一堆操作
      break;
      case ...开屏load
      break;
      case ...开屏getEcpm
      break;
       case ...开屏notifyLoss
      break;
      ....
      ....
  }

  override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
    channel.setMethodCallHandler(null)
  }
}

2. 代码架构设计

整个插件开发过程,我们使用一个MethodChannel进行Flutter和各端的通信和调用,而整个SDK功能划分为【初始化、开屏、插屏、原生、自渲染】五个模块,而每个模块可能有十多个方法都会调用,整个调用加起来可能几十个方法调用,每一个调用涉及到一个庞大的功能,可能导致此页面各个模块业务逻辑耦合在一起。最简单的我这里将这五个模块单独管理类进行管理,相互解耦。

可能有开发者使用五个Plugin [AdsSdkPlugin,AdsSplashPlugin,AdsNativePlugin....],每个Plugin对应一个MethodChannel进行模块划分,当然也可以,但是我发现插件项目自动生成的AdsSdkPlugin会被依赖到依赖了插件的项目中的GeneratedPluginRegistrant中,而这里统一默认注册了插件项目默认生成的AdsSdkPlugin。如果你创建了五个Plugin,每次项目依赖都会导致其他Pluging丢失,只有默认的AdsSdkPlugin存在。所以推荐使用默认生成的AdsSdkPlugin,而不是创建多个Plugin进行代码模块划分。

首先创建 AMPSEventManager 单例进行管理MethodChannel分发。在onMethodCall中根据Flutter端invoke的方法名称进行判断属于那个模块,再次分发到对应的管理对象中【AMPSSDKInitManager、AMPSSplashManager、AMPSInterstitialManager...】。

image.png

package com.example.amps_sdk.manager
class AMPSEventManager private constructor() : MethodCallHandler {

    private var channel: MethodChannel? = null
    private var mContext: WeakReference<Activity>? = null // 在 Android 中通常使用 Context

    companion object {
        private var sInstance: AMPSEventManager? = null
        fun getInstance(): AMPSEventManager {
            return sInstance ?: synchronized(this) {
                sInstance ?: AMPSEventManager().also { sInstance = it }
            }
        }
    }

    fun setContext(context: Activity) {
        this.mContext = WeakReference(context) // 存储 application context 避免内存泄漏
    }

    fun getContext(): Activity? {
        return this.mContext?.get()
    }

    /**
     * 初始化 MethodChannel 并设置回调处理器
     * @param binaryMessenger Flutter引擎的BinaryMessenger
     */
    fun init(binaryMessenger: BinaryMessenger) {
        if (channel == null) {
            channel = MethodChannel(binaryMessenger, "amps_sdk") // "amps_sdk" 是通道名称
            channel?.setMethodCallHandler(this) // 将当前类设置为回调处理器
        }
    }

    /**
     * 处理来自 Flutter 的方法调用
     */
    override fun onMethodCall(call: MethodCall, result: Result) {
        when {
            InitMethodNames.contains(call.method) -> {//InitMethodNames 初始化相关的所有方法名称
                AMPSSDKInitManager.getInstance().handleMethodCall(call, result)
            }
            SplashMethodNames.contains(call.method) -> {
                AMPSSplashManager.getInstance().handleMethodCall(call, result)
            }
            InterstitialMethodNames.contains(call.method) -> {
                //AMPSInterstitialManager.getInstance().handleMethodCall(call, result)
            }
            NativeMethodNames.contains(call.method) -> {
                //AMPSNativeManager.getInstance().handleMethodCall(call, result)
            }
            else -> {
                result.notImplemented() // 如果方法名未被识别
            }
        }
    }

    /**
     * 从原生端向 Flutter 发送消息
     * @param method 方法名
     * @param args 参数,可以是 null 或任何 Flutter 支持的类型
     */
    fun sendMessageToFlutter(method: String, args: Any?) { // args 类型改为 Any? 更灵活
        channel?.invokeMethod(method, args)
    }

    /**
     * 释放资源,清除 MethodChannel 的回调处理器和 Context
     */
    fun release() {
        channel?.setMethodCallHandler(null)
        channel = null // 可选,如果不再需要这个channel实例
        mContext = null
    }
}

3. 集成原生 SDK 依赖

要真正调用您的原生广告 SDK,需要在 Android 插件的配置文件中添加依赖。

打开 android/build.gradle (插件根目录下的 android 文件夹内),在 dependencies 块中添加您的原生 SDK 依赖。

这种方式在开发过程中可用,但是广告SDK的具体字节码是不会打包到最终包体的,还需要三方在对应项目Android模块重新依赖广告SDK。比较麻烦,最好是将其发布到maven仓库,避免复杂的依赖,当然可以使用本地maven仓库形式进行依赖,三方只需要在对应部分增加maven依赖配置即可。

allprojects {
    repositories {
        google()
        mavenCentral()
        maven {
            name = "myrepo"
            url = uri("file://${rootProject.projectDir.parentFile.parentFile}/android/m2repository")
        }
    }
}

对于本地maven构建,查看项目中build.gradle.kts

group = "com.example.ads_sdk"
version = "1.0-SNAPSHOT"

// At the top of your build.gradle
String mavenLocalRepoPath = "${rootProject.projectDir.toURI()}/m2repository"
// Ensure the directory exists
new File(mavenLocalRepoPath).mkdirs()
buildscript {
    ext.kotlin_version = "2.1.0"
    repositories {
        google()
        mavenCentral()
        maven {
            url "file:///D:/pgram/ads_sdk/android/m2repository"
        }
    }

    dependencies {
        classpath("com.android.tools.build:gradle:8.10.1")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
    }
}

allprojects {
    repositories {
        google()
        mavenCentral()
        maven {
            name = "myrepo"
            url = uri("file:///${mavenLocalRepoPath}")
        }
    }
}

apply plugin: "com.android.library"
apply plugin: "kotlin-android"
apply plugin: "maven-publish"
publishing {
    publications {
        // 第一个 AAR 的 Publication
        libraryOne(MavenPublication) {
            groupId = 'com.example'
            artifactId = 'amps-sdk'
            version = '1.0'
            //示例:假设你的模块名为 'amps_sdk' (与项目名一致)
            //并且你要发布 release AAR
            // 这个路径是相对于当前 build.gradle 所在模块的
            artifact "libs/beiziSDK_v5.3.0.3.aar"//在同一目录层级。所以可以写成libs/amps.aar
        }
        release(MavenPublication) {
            groupId = 'com.example'
            artifactId = 'common'
            version = '1.0'
            //示例:假设你的模块名为 'amps_sdk' (与项目名一致)
            //并且你要发布 release AAR
            // 这个路径是相对于当前 build.gradle 所在模块的
            artifact "libs/common_5.1.1.1.aar"
        }
    }
    repositories {
        maven {
            name = 'myrepo'
            url = mavenLocalRepoPath
        }
    }
}
//通过调试tasks调试路径:不需要调试之后删除即可
tasks.register("anotherTask") {
    doLast {
        //def mavenLocalRepo = file("m2repository")
        def a = uri("file://${rootProject.projectDir.parentFile.parentFile}/android/m2repository")
        logger.lifecycle(">>>>> [EXECUTION] 路径0=${a}")
        logger.lifecycle(">>>>> [EXECUTION] 路径1=${rootProject.projectDir.toURI()}") // 使用 lifecycle 确保默认可见
        logger.lifecycle(">>>>> [EXECUTION] 路径1=${rootProject.projectDir.toURI()}") // 使用 lifecycle 确保默认可见
        logger.lifecycle(">>>>> [EXECUTION] 路径2=${rootProject.projectDir.parentFile.parentFile}") // 使用 lifecycle 确保默认可见
    }
}

android {
    namespace = "com.example.amps_sdk"

    compileSdk = 36

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }

    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_11.toString()
    }

    sourceSets {
        main.java.srcDirs += "src/main/kotlin"
        test.java.srcDirs += "src/test/kotlin"
    }

    defaultConfig {
        minSdk = 24
    }

    dependencies {
        //compileOnly fileTree(dir: 'libs', include: '*.aar')
        // 依赖第一个 AAR (amps-sdk)
        api ('com.example:amps-sdk:1.0')
        // 依赖第二个 AAR (common)
        api ('com.example:common:1.0')
        implementation ('androidx.annotation:annotation:1.8.0')
        implementation ('androidx.appcompat:appcompat:1.7.1')
        implementation ('com.google.android.material:material:1.13.0')
        testImplementation ('junit:junit:4.13.2')
        androidTestImplementation ('androidx.test.ext:junit:1.3.0')
        androidTestImplementation ('androidx.test.espresso:espresso-core:3.7.0')
        testImplementation("org.jetbrains.kotlin:kotlin-test")
        testImplementation("org.mockito:mockito-core:5.19.0")
    }

    testOptions {
        unitTests.all {
            useJUnitPlatform()

            testLogging {
                events "passed", "skipped", "failed", "standardOut", "standardError"
                outputs.upToDateWhen {false}
                showStandardStreams = true
            }
        }
    }
}

六、事件回调处理 (EventChannel)

原生 SDK 通常会通过**回调(Callback)**来通知应用广告加载成功、展示或点击等事件。Flutter 使用 EventChannel 来处理这种原生到 Dart 的持续数据流。

1. Dart 层 (EventChannel)

在example/lib/main中调用AMPSAdSdk().init(),其本质是MethodChannel通过invoke调用原生端对于MethodChannel对应方法名称。

Future<void> init(AMPSInitConfig sdkConfig,AMPSIInitCallBack callBack) async {
  _callBack = callBack;
  // 使用时
  await AdsSdk.channel.invokeMethod(
    AMPSAdSdkMethodNames.init,
    sdkConfig.toMap(AMPSAdSdk.testModel),
  );
}

原生端Android监听位置匹配到AMPSAdSdkMethodNames.INIT,进行真正广告SDK调用。


class AMPSSDKInitManager private constructor() {

    companion object {
        @Volatile
        private var instance: AMPSSDKInitManager? = null

        fun getInstance(): AMPSSDKInitManager {
            return instance ?: synchronized(this) {
                instance ?: AMPSSDKInitManager().also { instance = it }
            }
        }
    }

    
    fun handleMethodCall(call: MethodCall, result: Result) {
        val method: String = call.method
        val flutterParams: Map<String, Any>? = call.arguments as? Map<String, Any>

        when (method) {
            AMPSAdSdkMethodNames.INIT -> {
                val context = AMPSEventManager.getInstance().getContext()
                if (context != null && flutterParams != null) {
                    val ampsConfig = AMPSInitConfigConverter().convert(flutterParams)
                    initAMPSSDK(ampsConfig, context)
                    result.success(true)
                } else {
                    if (context == null) {
                        result.error("CONTEXT_UNAVAILABLE", "Android context is not available.", null)
                    } else {
                        result.error("INVALID_ARGUMENTS", "Initialization arguments are missing or invalid.", null)
                    }
                }
            }
            else -> result.notImplemented()
        }
    }

    fun initAMPSSDK(ampsInitConfig: AMPSInitConfig?, context: Context) {
        val callback = object : IAMPSInitCallback {
            override fun successCallback() {
                sendMessage(AMPSInitChannelMethod.INIT_SUCCESS)
            }

            override fun failCallback(p0: AMPSError?) {
                sendMessage(AMPSInitChannelMethod.INIT_FAILED, mapOf("code" to p0?.code, "message" to p0?.message))
            }
        }

        if (ampsInitConfig != null) {
            SDKLog.setLogLevel(SDKLog.LOG_LEVEL.LOG_LEVEL_ALL);
            AMPSSDK.init(context, ampsInitConfig,callback)
        }
    }

    fun sendMessage(method: String, args: Any? = null) {
        AMPSEventManager.getInstance().sendMessageToFlutter(method, args)
    }
}

原生端SDK收到初始化SDK结果之后,通过AMPSEventManager.getInstance().sendMessageToFlutter(method, args)也就是MethodChannel最终通知Flutter端结果。Flutter端收到结果,通过callBack回调给用户。

总结

文章基本对 Flutter 插件开发实战、桥接原生 SDK 讲解了一遍——从Platform Channel 通信原理的底层逻辑,到FVM 多版本管理的环境搭建,再到Dart 层统一接口设计,原生层解耦架构实现的全流程开发,最后覆盖双向事件流转的联调关键,完整覆盖了多平台(Android、iOS、HarmonyOS)广告 SDK 桥接的核心环节,原生回调转发,模块解耦等实战痛点提供了具体方案(如构造者模式封装参数、AMPSEventManager 统一分发事件),可直接作为插件开发的落地参考。 当然,实际开发中可能还会遇到更多细节问题:比如 iOS 端的 Pod 依赖配置、HarmonyOS 端的 HAP 包构建适配、原生 SDK 异步回调的线程安全处理,或是插件发布到 Pub 仓库的规范配置等。如果大家在实操中遇到这类疑问,或是对现有流程有优化建议,都可以在评论区讨论交流。后续也会针对这些延伸场景补充更细致的教程,比如多 Channel 通信优化、插件版本迭代的兼容性处理等内容,帮助大家更顺畅地完成 Flutter 与原生能力的桥接工作。

Flutter中的动效实现方式

2025年10月11日 15:34

1. 动效实现方式

1.1 动效实现方式

  1.1.1 隐式动画

  1. 定义

通过使用 Flutter 的 动画库,你可以为 UI 中的组件添加运动和创建视觉效果。你可以使用库中的一套组件来管理动画,这些组件统称为隐式动画隐式动画组件,其名称源于它们都实现了 ImplicitlyAnimatedWidget 类。使用隐式动画,你可以通过设置一个目标值,驱动 widget 的属性进行动画变换;每当目标值发生变化时,属性会从旧值逐渐更新到新值。通过这种方式,隐式动画内部实现了动画控制,从而能够方便地使用— 隐式动画组件会管理动画效果,用户不需要再进行额外的处理。

image.png

  实现方式示例    示例:使用 AnimatedOpacity widget 进行透明度动画

  1. 选择要进行动画的 widget 属性

  2. 想要创建淡入效果,可以使用 AnimatedOpacity widget 对 opacity 属性进行动画。将 Column widget 换成 AnimatedOpacity widget:

    @override
    Widget build(BuildContext context) {
      return ListView(children: <Widget>[
        Image.network(owlUrl),
        TextButton(
          child: const Text(
            'Show Details',
            style: TextStyle(color: Colors.blueAccent),
          ),
          onPressed: () => {},
        ),
        const Column(
          children: [
            Text('Type: Owl'),
            Text('Age: 39'),
            Text('Employment: None'),
          ],
        ),
        AnimatedOpacity(
          child: const Column(
            children: [
              Text('Type: Owl'),
              Text('Age: 39'),
              Text('Employment: None'),
            ],
          ),
        ),
      ]);
    }
    
  3. 为动画属性初始化一个状态变量

class _FadeInDemoState extends State<FadeInDemo> {
  double opacity = 0;

  @override
  Widget build(BuildContext context) {
    return ListView(children: <Widget>[
      // ...
      AnimatedOpacity(
        opacity: opacity,
        child: const Column(

4. 为动画设置一个时长

除了 opacity 参数以外,AnimatedOpacity 还需要为动画设置 duration。在下面的例子中,动画会以两秒的时长运行:

AnimatedOpacity(
  duration: const Duration(seconds: 2),
  opacity: opacity,
  child: const Column(

5. 为动画设置一个触发器,并选择一个结束值

TextButton(
  child: const Text(
    'Show Details',
    style: TextStyle(color: Colors.blueAccent),
  ),
  onPressed: () => {},
  onPressed: () => setState(() {
    opacity = 1;
  }),
),

使用动画曲线

  1. 隐式动画还允许你在 duration 时长内控制动画的 速率 变化。用来定义这种速率变化的参数是 Curve,或者 Curves 这些已经预定义的曲线。
  2. 在 上面的示例中可以添加一个 curve 参数,然后将常量 easeInOutBack 传递给 curve ,即可以自定义动效曲线
AnimatedOpacity(
  duration: const Duration(seconds: 2),
  opacity: opacity,
  curve: Curves.easeInOutBack,
  child: const Column(
    children: [
      Text('Type: Owl'),
      Text('Age: 39'),
      Text('Employment: None'),
    ],
  ),
),

image.png

  1. 其他动效曲线都可以在Curves类中查看各种曲线的定义

1.1.2 动画控制器

介绍

  AnimationController 是个特殊的 Animation 对象,每当硬件准备新帧时,他都会生成一个新值。默认情况下,AnimationController 在给定期间内会线性生成从 0.0 到 1.0 的数字。

基本动画类

  • Animation 对象在一段时间内,持续生成介于两个值之间的插入值
  • CurvedAnimation 定义动画进程为非线性曲线。
  • AnimationController 是个特殊的 Animation 对象,每当硬件准备新帧时,他都会生成一个新值
  • Tween 定义动画插入不同的范围或数据类型。

实现方式示例

简单的元素放大示例

  1. 实现SingleTickerProviderStateMixinvsync 对象(vsync 的存在防止后台动画消耗不必要的资源)
class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin 

2. 定义动画控制器AnimationController

AnimationController controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);

3. 定义动画Animation

Animation<double> animation = Tween<double>(begin: 0, end: 300).animate(controller);

4. widget上的使用

Container(
    height: 300,
    width: 300,
    height: animation.value,
    width: animation.value,
    child: const FlutterLogo(),
  )

  监控动画过程

  使用 addStatusListener() 作为动画状态的变更提示,比如开始,结束,或改变方向。

animation = Tween<double>(begin: 0, end: 300).animate(controller)
  ..addStatusListener((status) => print('$status'));

  上面的例子在起始或结束时,使用 addStatusListener() 反转动画。制造“呼吸”效果

  animation = Tween<double>(begin: 0, end: 300).animate(controller)
    ..addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        controller.reverse();
      } else if (status == AnimationStatus.dismissed) {
        controller.forward();
      }
    })

  1.1.3 动效组件 Lottie rive

  Lottie

  使用方式

    Lottie.    asset    (
  'assets/images/loading_img.json',
  height: 24.w,
  width: 24.w,
  package: Constant.    package    ,
)
    控制动画播放
    // 定义控制器
AnimationController _controller = AnimationController(vsync: this);

// 初始化时控制器赋值
Lottie.asset(
  'assets/LottieLogo1.json',
  controller: _controller,
  height: 300,
  onLoaded: (composition) {
    setState(() {
      _controller.duration = composition.duration;
    });
  },
);

// 使用控制器,同:动画控制器
_controller.forward();
_controller.stop();

  Rive

  介绍:Rive 是一个实时交互式设计和动画工具,该库允许您使用高级 API 完全控制 Rive 文件,以实现简单的交互和动画,以及使用低级 API 在单个画布中为多个画板、动画和状态机创建自定义渲染循环。

  使用方式:使用animation进行控制

  动画定义:

image.png

  动画控制实现:

// 定义画板
Artboard? _artboard;

// 初始化画板赋值
RiveAnimation.asset(
  'assets/circle_color_test.riv',
  animations: [_riveAnimation],
  fit: BoxFit.cover,
  onInit: (artboard) {
    _artboard = artboard;
  },
);

// 调用画板修改Animation
SimpleAnimation _animationBlue = SimpleAnimation('blue')
_artboard?.addController(_animationBlue)
// 或者直接修改Animation
setState(() {
  _riveAnimation = 'blue';
});

// 调用Artboard控制动画播放
_artboard?.pause();
_artboard?.play();

  使用状态机进行控制:

  动画定义

image.png

动画控制实现

// 定义状态机属性
SMINumber? color;

// 初始化状态机赋值
void _onRiveInit(Artboard artboard) {
  final StateMachineController? controller =
      StateMachineController.fromArtboard(artboard, 'State Machine 1');
  artboard.addController(controller!);
  color = controller.getNumberInput('color');
}

RiveAnimation.asset(
  'packages/package/assets/theme/color_sel.riv',
  fit: BoxFit.cover,
  onInit: _onRiveInit,
)

// 使用状态机控制动画播放
color?.change(1);
color?.change(2);

  组合使用示例:

7MHXxJGHc9s4savv81yem8sg8amwb

hackernoon.com/lang/zh/riv…

Screenrecorder

对比

image.png

参考:medium.com/@sandeepkel…

1.1.4图片动效资源

实现方式

针对gif和webp等图片资源实现的动效,flutter官方SDK提供的Image.asset就可以正常显示:

Image.asset(
  'assets/theme/88.webp',
  height: 42.w,
  width: 42.w,
  fit: BoxFit.cover,
  gaplessPlayback: false,
  bundle: PlatformAssetBundle(),
  package: 'package',
)

优化

但是官方SDK只能显示,无法进行播放进度控制和循环播放等操作(目前已有提案,但是仍未实现)

  ** [Proposal] Add AnimatedImageController to communicate with animated images (GIF, AVIF, APNG, etc) **#111150

  **Improve Images in Flutter to allow for more control over GIFs such as playback status and speed. **#59605

因此使用第三方的组件进行播放进度控制:gif: ^2.3.0

实现原理

  1. 使用PaintingBinding将gif/webp等动效文件的帧信息获取并暂存
bytes = provider.bytes;

final buffer = await ImmutableBuffer.fromUint8List(bytes);
Codec codec = await PaintingBinding.instance.instantiateImageCodecWithSize(
  buffer,
);

List<ImageInfo> infos = [];
Duration duration = Duration();

for (int i = 0; i < codec.frameCount; i++) {
  FrameInfo frameInfo = await codec.getNextFrame();
  infos.add(ImageInfo(image: frameInfo.image));
  duration += frameInfo.duration;
}

2. 使用GifController(AnimationController)控制当前在哪一帧,然后获取对应的帧数据进行展示

// ......
  setState(() {
    _frameIndex = _frames.isEmpty
        ? 0
        : ((_frames.length - 1) * _controller.value).floor();
  });
// ......

@override
Widget build(BuildContext context) {
  final RawImage image = RawImage(
    image: _frame?.image,
    width: widget.width,
    height: widget.height,
    scale: _frame?.scale ?? 1.0,
    color: widget.color,
    colorBlendMode: widget.colorBlendMode,
    fit: widget.fit,
    alignment: widget.alignment,
    repeat: widget.repeat,
    centerSlice: widget.centerSlice,
    matchTextDirection: widget.matchTextDirection,
  );
  return image;
}

  存在的问题

  Displaying GIFs causing memory increase and app crash#65815

  ** **[Performance] Gif will make GC frequently, and it'll make the phone heat up****#80702

1.1.5 序列帧动效

实现方式:

  1. UI将动效导出序列帧切图,定义prefix和对应的index
image.png
  1. 定义帧数使用的Tween Animation
AnimationController _animationController =
    AnimationController(vsync: this, duration: widget.duration);
Animation<int> _animation = IntTween(
      begin: beginIndex,
      end: endIndex)
  .animate(_animationController!);

3. 使用AnimatedBuilderImage.asset渲染动效

AnimatedBuilder(
    animation: _animation!,
    builder: (BuildContext context, Widget? child) {
      return Image.asset(
        '${imagePrefix}${_animation?.value}.${imageType}',
        package: Constant.package,
        gaplessPlayback: true,
        fit: BoxFit.cover,
        scale: _dpr(),
        width: double.infinity,
        height: double.infinity,
      );
    });

1.2 性能需求

  1. 使用V9.0.0线上版本baseline进行本地修改,使用SoloX进行性能数据抓取(参照SoloX(2.5.3)使用说明_mac)。

  2. 分别对比不添加动效和添加动效的内存消耗

    1. 将新建的账号添加一台智能体脂秤设备,在release模式下重启app后的数据作为基准数据
    2. 对比进入基准数据、动画执行、动画执行完毕稳定后的数据
  3. 结果

    动效类型 FD/sync_file CPU 内存
    基准 527/57 2.92 378.16
    隐式动画、路由动画 524/56 3.12多次执行6.19 387.07
    控制器动画AnimationController 523/56 5.06 380.33
    动效组件 Lottie 529/59 6.69 391.51
    动效组件 Lottie循环执行 526/57 7.23 382.85
    动效组件rive 525/52 4.48 377(+12)
    图片动效资源 webp/gif 530/60多次执行:620/171 5.34 379.11多次执行:459.58
    加了控制器的webp/gif动效 594/123多次执行无变化动效移除后:521.52/52 1.35→5.3 394.65→405.16
    序列帧动效(10) 540/63 6.16 382.67
    序列帧动效(100) 643/174 节能3.48 389(355)

1.3 扩展

1.3.1与路由组件结合实现页面切换效果

1Screenrecorder

2Screenrecorder

代码

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

class TransformRoute<T> extends PageRoute<T> {
  final WidgetBuilder builder;
  final BuildContext childContext;

  TransformRoute(
      {required this.transitionDuration,
      required this.useRootNavigator,
      required this.builder,
      required this.childContext})
      : super();

  @override
  final Duration transitionDuration;

  final bool useRootNavigator;

  // Defines the position and the size of the (opening) [OpenContainer] within
  // the bounds of the enclosing [Navigator].
  final RectTween _rectTween = RectTween();

  AnimationStatus? _lastAnimationStatus;
  AnimationStatus? _currentAnimationStatus;

  @override
  TickerFuture didPush() {
    _takeMeasurements(navigatorContext: childContext);

    animation!.addStatusListener((AnimationStatus status) {
      _lastAnimationStatus = _currentAnimationStatus;
      _currentAnimationStatus = status;
    });

    return super.didPush();
  }

  @override
  bool didPop(T? result) {
    _takeMeasurements(
      navigatorContext: subtreeContext!,
      delayForSourceRoute: true,
    );
    return super.didPop(result);
  }

  @override
  void dispose() {
    super.dispose();
  }

  void _takeMeasurements({
    required BuildContext navigatorContext,
    bool delayForSourceRoute = false,
  }) {
    final RenderBox navigator = Navigator.of(
      navigatorContext,
      rootNavigator: useRootNavigator,
    ).context.findRenderObject()! as RenderBox;
    final Size navSize = _getSize(navigator);
    _rectTween.end = Offset.zero & navSize;

    void takeMeasurementsInSourceRoute([Duration? _]) {
      if (!navigator.attached) {
        return;
      }
      _rectTween.begin = _getRect(childContext, navigator);
    }

    if (delayForSourceRoute) {
      SchedulerBinding.instance
          .addPostFrameCallback(takeMeasurementsInSourceRoute);
    } else {
      takeMeasurementsInSourceRoute();
    }
  }

  Size _getSize(RenderBox render) {
    assert(render.hasSize);
    return render.size;
  }

  Rect _getRect(BuildContext context, RenderBox ancestor) {
    final RenderBox render = context.findRenderObject()! as RenderBox;
    assert(render.hasSize);
    return MatrixUtils.transformRect(
      render.getTransformTo(ancestor),
      Offset.zero & render.size,
    );
  }

  bool get _transitionWasInterrupted {
    bool wasInProgress = false;
    bool isInProgress = false;

    switch (_currentAnimationStatus) {
      case AnimationStatus.completed:
      case AnimationStatus.dismissed:
        isInProgress = false;
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
        isInProgress = true;
      case null:
        break;
    }
    switch (_lastAnimationStatus) {
      case AnimationStatus.completed:
      case AnimationStatus.dismissed:
        wasInProgress = false;
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
        wasInProgress = true;
      case null:
        break;
    }
    return wasInProgress && isInProgress;
  }

  void closeContainer({T? returnValue}) {
    Navigator.of(subtreeContext!).pop(returnValue);
  }

  @override
  Widget buildPage(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
    return Align(
      alignment: Alignment.topLeft,
      child: AnimatedBuilder(
        animation: animation,
        builder: (BuildContext context, Widget? child) {
          final Animation<double> curvedAnimation = CurvedAnimation(
            parent: animation,
            curve: Curves.fastOutSlowIn,
            reverseCurve:
                _transitionWasInterrupted ? null : Curves.fastOutSlowIn.flipped,
          );
          final Rect rect = _rectTween.evaluate(curvedAnimation)!;
          return SizedBox.expand(
            child: Align(
              alignment: Alignment.topLeft,
              child: Transform.translate(
                offset: Offset(rect.left, rect.top),
                child: SizedBox(
                  width: rect.width,
                  height: rect.height,
                  child: Material(
                    clipBehavior: Clip.antiAlias,
                    animationDuration: Duration.zero,
                    child: Stack(
                      fit: StackFit.passthrough,
                      children: <Widget>[
                        // Open child fading in.
                        FittedBox(
                          fit: BoxFit.fitWidth,
                          alignment: Alignment.topLeft,
                          child: SizedBox(
                            width: _rectTween.end!.width,
                            height: _rectTween.end!.height,
                            child: Builder(
                              builder: (BuildContext context) {
                                return builder(context);
                              },
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  @override
  bool get maintainState => true;

  @override
  Color? get barrierColor => null;

  @override
  bool get opaque => true;

  @override
  bool get barrierDismissible => false;

  @override
  String? get barrierLabel => null;
}

  1.3.2 与Overlay组件结合实现消息横幅

RPReplay_Final

主要代码

void _createAnimation() {
  // 此处通过key去获取Widget的Size属性
  RenderBox renderBox =
      _childKey.currentContext?.findRenderObject() as RenderBox;
  Size size = renderBox.size;
  double deltaY = size.height; // 该值为位移动画需要的位移值
  // 如果fade动画不存在,则创建一个新的fade动画
  _fade = Tween<double>(begin: _fadeAnimate ? 0.0 : 1.0, end: 1.0)
      .animate(CurvedAnimation(parent: controller!, curve: Curves.ease));

  _translate = Tween<double>(begin: -deltaY, end: 0).animate(CurvedAnimation(
      parent: controller!, curve: Curves.ease)); // 前15%的时间用于执行平移动画
}

AnimationController createAnimationController() {
  return AnimationController(
    duration: const Duration(milliseconds: 400),
    debugLabel: debugLabel,
    vsync: navigator!,
  );
}

Widget _buildAnimation(BuildContext context, Widget? child) {
  return Transform.translate(
    offset: Offset(0, _translate?.value ?? 0),
    child: Opacity(
        opacity: _fade?.value ?? 0,
        child: child), // 此处使用translate.value不断取值来刷新child的偏移量
  );
}

  child: AnimatedBuilder(
    builder: _buildAnimation,
    animation: controller!,
    child: child,
  ),  

2. 总结

动效类型 优点 缺点 动效文件大小 是否支持网络加载 使用难度 是否支持控制器
隐式动画 使用方便,通过state控制 无法实现复杂的动效效果 / / 使用方便,通过state控制
控制器动画AnimationController 使用复杂,需要自定义参数和曲线以及运行时机 / / 使用复杂,需要自定义参数和曲线以及运行时机
动效组件 Lottie 实现方便,无需复杂切图;执行完成后性能恢复正常;不会导致fd增加 执行时占用CPU,执行完成后释放 开关机:7KB节能状态:128KB 支持网络加载不支持缓存 使用方便,Lottie.asset``Lottie.network
动效组件rive 动效文件更小;能更好的控制动效状态和做出更多的交互 需要修改ndkVersion "25.1.8937393" 三种颜色的水波纹放大:3KB 支持网络加载 使用难度高,涉及到artboard、animations、stateMachines多种控制
图片动效资源 webp/gif 直接使用Image.asset展示时多次执行的情况下会导致fd占用增加且执行完成后不回落 开关机:gif:11/14KBwebp:58/152KB节能状态:gif:3.7MBwebp:5.8MB 支持网络加载支持缓存 使用方便Image.asset
加了控制器的webp/gif动效 与非受控相比执行时fd不会增加,且组件移除后快速回落 加载时导致fd大量增加 支持网络加载不支持缓存 使用方式一般Gif(controller: controller, image: NetworkImage())
序列帧动效 执行时不会导致fd和内存增加 需要大量图片资源,不适用与server动态配置;执行时会导致fd和内存增加 开关机:2KB10/2KB22节能状态:82KB*119 不便于网络加载 使用复杂FrameAnimationImage

3. 参考资料

  1. docs.flutter.cn/codelabs/im…
  2. [Proposal] Add AnimatedImageController to communicate with animated images (GIF, AVIF, APNG, etc)#111150
  3. Improve Images in Flutter to allow for more control over GIFs such as playback status and speed.#59605
  4. Displaying GIFs causing memory increase and app crash#65815
  5. [Performance] Gif will make GC frequently, and it'll make the phone heat up#80702
  6. docs.flutter.cn/ui/animatio…
  7. github.com/xvrh/lottie…
  8. rive.app/community/d…
  9. hackernoon.com/lang/zh/riv…
  10. medium.com/@sandeepkel…

4. 团队介绍

三翼鸟数字化技术平台-网器场景」负责网器设备基础数据和计算、规则引擎、网器绑定、网器控制、安防音视频、网器跨平台接入验证等业务,服务产业及海尔智家线上用户;负责网器管理平台建设,提供产业设备基础数据底座、研发产业跨平台网器管理工具等,致力于提升用户交互体验和网器产品的智能化水平。

FlutterBoost在iOS26真机运行崩溃问题

作者 干中学
2025年9月17日 14:41

背景:

iPhone11ProMax升级到iOS26

Xcode16.4,运行公司老项目,项目集成了FlutterBoost

报错截图:

image.png

Crash occurred when compiling unknown function in unoptimized JIT mode in unknown pass

崩溃信息中这句话很重要,分析了一下报错那行代码是初始化flutter引擎时找不到main函数,因为断点打印了options.dartEntryPoint是main,结合控制台打印里的JIT模式,以及还有一个部分是权限相关,如下图:

image.png

猜测可能和debug运行模式下访问本地的FlutterBoost代码文件权限有关,于是,在Xcode里修改run模式下的Build Configuration为release

image.png 再次运行,发现出现了一次弹窗Xcode要访问本地文件,我点了允许(忘记截图,后面运行没再出现),然后就运行正常不再崩溃。

具体原因不清楚,猜测是和文件读取权限有关,看github上也有人问了类似问题,期待后续FlutterBoost官方修复此问题。

❌
❌