本文档主要介绍加加移动服务平台Flutter 插件的开发和集成,适用于有一定Flutter基础的开发人员。
* 支持版本说明
> * Flutter SDK >= 1.5.4
> * Android Studio 3.0+ / Xcode 11+
> * JDK 1.8 / Swift 5.0+
## 插件集成
### host配置
针对Windows环境,C:\Windows\System32\drivers\etc 的host文件加入
185.199.108.133 raw.githubusercontent.com
针对Mac环境,在/etc/hosts下加入
### 依赖
在工程/pubspec.yaml中,加入依赖:
目前依赖的是anji.sdk.appsp:aar:0.0.2(之前版本是0.0.1)
aj_flutter_appsp:
git:
url: https://gitee.com/anji-plus/aj_flutter_plugins.git
path: aj_flutter_appsp
### 获取插件
终端输入
flutter packages get
或者点击右上角Packages get
### 引用
```java
import 'package:aj_flutter_appsp/aj_flutter_appsp_lib.dart';
详细使用请参考插件的example
@override
void initState() {
super.initState();
_initAppSp();
}
_initAppSp() async {
//初始化
var debuggable = !bool.fromEnvironment("dart.vm.product");
await AjFlutterAppSp.init(
//appKey,创建应用时生成的,作为和服务端通信的标识
appKey: "860dc3bd5faa489bb7ab6d087f8ee6e5",
//请求的基础地址
host: "https://openappsp.anji-plus.com",
//是否打开debug开关,非生产默认打开
debug: debuggable);
}
_update() async { //关键代码
SpRespUpdateModel updateModel =
await AjFlutterAppSp.getUpdateModel();
if (!mounted) {
return;
}
if (updateModel == null) {
Scaffold.of(context).showSnackBar(
SnackBar(content: Text("没有更新信息")),
);
return;
}
}
* 公告信息获取
```java
import 'package:aj_flutter_appsp/aj_flutter_appsp_lib.dart';
_requestNoticeType() async {
//无需改造数据,用服务器返回数据,下面的都是模拟的数据
//ignore
SpRespNoticeModel noticeModel = await AjFlutterAppSp.getNoticeModel();
if (!mounted) {
return;
}
if (noticeModel == null ||
noticeModel.repData == null ||
noticeModel.repData.isEmpty) {
var snackBar = SnackBar(content: Text("没有公告信息"));
_scaffoldkey.currentState.showSnackBar(snackBar);
return;
}
关于版本更新的弹出和下载更新,需要注意:
1, android目录下,AndroidManifest.xml中加入权限,其中包括网络访问、文件访问、apk安装的权限
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
2, android目录下,AndroidManifest.xml中添加provider,方便Android7.0+版本升级
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="包名.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
authorities的格式是 包名.fileprovider
其中,file_paths.xml若没有则创建,在xml目录下,file_paths.xml内容如下:
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path
name="files_root"
path="Android/data/包名/" />
<external-path
name="external_storage_root"
path="." />
<root-path
name="root_path"
path="" />
</paths>
3,针对http,尤其在测试阶段,AndroidManifest.xml要注意加上networkSecurityConfig和usesCleartextTraffic,改成
<application
android:name="io.flutter.app.FlutterApplication"
android:icon="@mipmap/logo"
android:label="Flutter移动服务平台"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
4,记得用embedding V2
<meta-data
android:name="flutterEmbedding"
android:value="2" />
关于版本更新的弹出和下载更新,以及公告的展示,请参考我们的Flutter插件,附有Example
接口路径:https://openappsp.anji-plus.com/sp/phone/deviceInit
static const MethodChannel _channel = const MethodChannel('aj_flutter_appsp');
///设置基础地址,如果在开发测试场景会用到,生产时候记得改成生产地址,或者最好不要对暴露set方法
///[appKey] 应用唯一标识
///[host] 设置请求基础地址
///[debug] 是否打开日志开关,true为打开
static Future<String> init(
{String appKey = '', String host = '', bool debug = true}) async {
final String result = await _channel
.invokeMethod('init', {"appKey": appKey, "host": host, "debug": debug});
return result;
}
接口路径:https://openappsp.anji-plus.com/sp/phone/appVersion
import 'aj_flutter_appsp_lib.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
///获取版本信息
static Future<SpRespUpdateModel> getUpdateModel() async {
final String jsonStr = await _channel.invokeMethod('getUpdateModel');
SpRespUpdateModel updateModel =
SpRespUpdateModel.fromJson(json.decode(jsonStr));
return updateModel;
}
请求返回数据结构如下
SpRespUpdateModel 数据详情
{
"repCode": "0000", //业务返回码,0000表示成功
"repMsg": "成功", //业务日志
"repData": {
"downloadUrl": "app下载地址",
"mustUpdate": false, //是否强制更新,true为强制更新
"showUpdate": true, //是否允许弹出更新
"updateLog": "更新日志"
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
| repCode | String | 业务返回码,0000表示成功 |
| repMsg | String | 业务日志、异常信息 |
| repData | Object | 请求业务数据包、详情见下 |
repData 数据详情
| 字段 | 类型 | 说明 |
|---|---|---|
| downloadUrl | String | app下载地址 |
| mustUpdate | boolean | 是否强制更新,true为强制更新; false为非强制更新 |
| showUpdate | boolean | 是否提示更新:允许弹出更新 |
| updateLog | String | 更新日志 |
///下载apk并且安装
///返回 ”UrlFormatError“ =》url格式错误
///返回 ”notSamePackage“ =》不是统一包名
///返回 ”downloadApkFailed“ =》apk下载失败
Future<String> downloadApkAndInstall(String url, String path) async {
final String result = await _channel
.invokeMethod('downloadApkAndInstall', {"url": url, "path": path});
return result;
}
///apk安装
///返回 ”FilePathFormatError“ =》文件格式错误
Future<String> installApk(String path) async {
final String result =
await _channel.invokeMethod('installApk', {"path": path});
return result;
}
///apk下载取消
Future<String> cancelDownload() async {
final String result = await _channel.invokeMethod('cancelDownload');
return result;
}
///是否同一包名
Future<String> checkSamePackage(String path) async {
final String result =
await _channel.invokeMethod('checkSamePackage', {"path": path});
return result;
}
我们在插件实现文件下载的原因是, 用Dio插件下载文件默认可以dio.download(...),但是如果返回的不是静态资源而是文件流,那么这个下载是无效的, 但是用原生的HttpURLConnection下载是没问题的,为了做兼容,写在了插件。
相比之前版本,插件实现了几个功能: 1,避免重复下载,当下载完安装取消,再次安装无需下载 2,校验是否是APK文件,不是则弹出错误 3,校验是否同一包名,不同则报错 4,当点击取消按钮或者返回,下载任务取消
文件下载关键逻辑
// 获取本地文档目录
String dir = (await getExternalStorageDirectory()).path;
//保证唯一
file = new File('$dir/AJ_' +
new DateTime.now().millisecondsSinceEpoch.toString() +
'.apk');
String errorMsg = '';
String result = await appspPlugin.downloadApk(apkUrl, file.path);
print('downloadApkAndInstall result $result');
if ("UrlFormatError" == result) {
errorMsg = 'url格式错误';
} else if ("notSamePackage" == result) {
errorMsg = '需下载的包名不一致';
} else if ("downloadApkFailed" == result) {
errorMsg = 'apk下载失败';
} else if ("notApk" == result) {
errorMsg = '非APK文件';
}
if (errorMsg.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMsg)),
);
}
因为进度条是在flutter层,文件下载进度监听
try {
appspPlugin.getApkDownloadProgressCallback((result) {
setState(() {
widget.rate = result;
if (result >= 1) {
setState(() {
updateButtonEnable = true;
});
_cacheApkPath();
}
});
});
} on PlatformException {
print('PlatformException');
}
flutter主动触发apk安装
_pushAndInstall() async {
try {
String result = await appspPlugin.installApk(_fileMap['path']);
if ("FilePathFormatError" == result) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("文件格式错误")),
);
}
} on PlatformException catch (e) {
print("dart -PlatformException ");
} finally {}
}
apk安装的原生逻辑
//安装apk
private void installApk(String path) {
Intent intent = new Intent(Intent.ACTION_VIEW);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Uri contentUri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", new File(path));
intent.setDataAndType(contentUri, "application/vnd.android.package-archive");
} else {
intent.setDataAndType(Uri.fromFile(new File(path)), "application/vnd.android.package-archive");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
MethodResultWrapper wrapper = peekWraper();
if (wrapper != null) {
wrapper.success("installApkSuccess");
}
if (context != null) {
context.startActivity(intent);
}
}
接口路径:https://openappsp.anji-plus.com/sp/phone/appNotice
import 'aj_flutter_appsp_lib.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
///获取公告信息
static Future<SpRespNoticeModel> getNoticeModel() async {
final String jsonStr = await _channel.invokeMethod('getNoticeModel');
SpRespNoticeModel noticeModel =
SpRespNoticeModel.fromJson(json.decode(jsonStr));
return noticeModel;
}
请求返回数据结构如下
SpRespNoticeModel 数据详情
{
"repCode": "0000", //业务返回码,0000表示成功
"repMsg": "成功", //业务日志
"repData": {
"title": "公告标题",
"details": "公告内容",
"templateType": "dialog", //公告类型( 弹窗:dialog; 水平滚动:horizontal_scroll)
"templateTypeName": "公告"//公告模板名称
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
| repCode | String | 业务返回码,0000表示成功 |
| repMsg | String | 业务日志、异常信息 |
| repData | Object | 请求业务数据包、详情见下 |
repData 数据详情
| 字段 | 类型 | 说明 |
|---|---|---|
| title | String | 公告标题 |
| details | String | 公告内容 |
| templateType | String | 公告类型( 弹窗:dialog; 水平滚动:horizontal_scroll) |
| templateTypeName | String | 公告模板名称 |
在AjFlutterAppspPlugin.java
//为了解决并发问题,比如多次点击,异常情况时候容易出问题
private ConcurrentLinkedQueue<MethodResultWrapper> wrappers = new ConcurrentLinkedQueue<>();
@Override
public void onMethodCall(MethodCall call, Result result) {
removeAllWrapper();
addWraper(new MethodResultWrapper(result));
//初始化
if (call.method.equals("init")) {
String appKey = null;
String host = null;
boolean debug = true;
Object parameter = call.arguments();
if (parameter instanceof Map) {
appKey = (String) ((Map) parameter).get("appKey");
host = (String) ((Map) parameter).get("host");
debug = (Boolean) ((Map) parameter).get("debug");
init(appKey, host, debug);
}
//版本请求
} else if (call.method.equals("getUpdateModel")) {
checkVersion();
//公告获取
} else if (call.method.equals("getNoticeModel")) {
checkNotice();
} else {
MethodResultWrapper wrapper = peekWraper();
if (wrapper != null) {
wrapper.notImplemented();
}
}
}
/**
* 获取当前的result
*
* @return
*/
private MethodResultWrapper peekWraper() {
if (wrappers == null
|| wrappers.isEmpty()) {
return null;
}
return wrappers.remove();
}
/**
* 只考虑最后一次
*/
private void removeAllWrapper() {
if (wrappers == null) {
return;
}
wrappers.clear();
}
/**
* 加入唯一的result
*
* @param wrapper
*/
private void addWraper(MethodResultWrapper wrapper) {
if (wrappers == null) {
return;
}
wrappers.add(wrapper);
}
/**
* 初始化
*
* @param appKey 创建应用时生成的,作为和服务端通信的标识
* @param host 如果为空,认为用SDK默认请求地址
* @param debug 日志开关是否打开,默认打开
*/
private void init(String appKey, String host, boolean debug) {
AppSpConfig.getInstance()
.init(registrar.activity(), appKey)
//可修改基础请求地址
.setHost(host)
//正式环境可以禁止日志输出,通过Tag APP-SP过滤看日志
.setDebuggable(debug)
//务必要初始化,否则后面请求会报错
.deviceInit();
MethodResultWrapper wrapper = peekWraper();
if (wrapper != null) {
wrapper.success("");
}
}
/**
* 版本更新检查
*/
private void checkVersion() {
AppSpConfig.getInstance().getVersion(new IAppSpVersionCallback() {
@Override
public void update(AppSpModel<AppSpVersion> spModel) {
AppSpLog.d("Test updateModel is " + spModel);
MethodResultWrapper wrapper = peekWraper();
if (spModel == null) {
if (wrapper != null) {
wrapper.notImplemented();
}
} else {
//先转成json
if (spModel.getRepData() != null) {//有更新数据
if (wrapper != null) {
wrapper.success(new Gson().toJson(spModel));
}
} else {//无更新数据
AppSpModel tempModel = new AppSpModel<>();
tempModel.setRepCode(spModel.getRepCode());
tempModel.setRepMsg(spModel.getRepMsg());
if (wrapper != null) {
wrapper.success(new Gson().toJson(tempModel));
}
}
}
}
@Override
public void error(String code, String msg) {
//无更新数据
MethodResultWrapper wrapper = peekWraper();
AppSpModel spModel = new AppSpModel<>();
spModel.setRepCode(code);
spModel.setRepMsg(msg);
if (wrapper != null) {
wrapper.success(new Gson().toJson(spModel));
}
}
});
}
/**
* 公告信息获取
*/
private void checkNotice() {
AppSpConfig.getInstance().getNotice(new IAppSpNoticeCallback() {
@Override
public void notice(AppSpModel<List<AppSpNoticeModelItem>> noticeModel) {
AppSpLog.d("Test noticeModel is " + noticeModel);
MethodResultWrapper wrapper = peekWraper();
if (noticeModel == null) {
if (wrapper != null) {
wrapper.notImplemented();
}
} else if (noticeModel.getRepData() != null) {//有公告信息
if (wrapper != null) {
wrapper.success(new Gson().toJson(noticeModel));
}
} else {//无公告信息
//先转成json
AppSpModel tempModel = new AppSpModel<>();
tempModel.setRepCode(noticeModel.getRepCode());
tempModel.setRepMsg(noticeModel.getRepMsg());
if (wrapper != null) {
wrapper.success(new Gson().toJson(tempModel));
}
}
}
@Override
public void error(String code, String msg) {
//无公告信息
AppSpModel noticeModel = new AppSpModel<>();
noticeModel.setRepCode(code);
noticeModel.setRepMsg(msg);
MethodResultWrapper wrapper = peekWraper();
if (wrapper != null) {
wrapper.success(new Gson().toJson(noticeModel));
}
}
});
}
主工程build.gradle的release打包配置中,加入混淆配置
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
//混淆
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
//测试
debug {
signingConfig signingConfigs.debug
}
}
主工程下proguard-rules.pro文件加入
-dontwarn com.anji.appsp.sdk.**
-keep class com.anji.appsp.sdk.**{*;}
在AjFlutterAppspPlugin.m中
if ([call.method isEqualToString: @"init"]){
NSString *appKey = call.arguments[@"appKey"];
NSString *host = call.arguments[@"host"];
NSString *debug = call.arguments[@"debug"];
if (host != nil && host.length > 0) {
[[AppSpService shareService] initConfigWithAppkey:appKey debug:debug :host];
} else {
[[AppSpService shareService] initConfigWithAppkey:appKey debug:debug :nil];
}
}
if ([call.method isEqualToString: @"getUpdateModel"]) {
__weak typeof(self) weakSelf = self;
[[AppSpService shareService] checkVersionUpdateWithSuccess:^(NSDictionary* repData) {
result([weakSelf formateDictToJSonString:repData]);
} failure:^(NSDictionary* errorData) {
result([weakSelf formateDictToJSonString:errorData]);
}];
}
if ([call.method isEqualToString:@"getNoticeModel"]) {
__weak typeof(self) weakSelf = self;
[[AppSpService shareService] getNoticeInfoWithSuccess:^(NSDictionary* repData) {
result([weakSelf formateDictToJSonString:repData]);
} failure:^(NSDictionary* errorData) {
result([weakSelf formateDictToJSonString:errorData]);
}];
}
地址: https://gitee.com/anji-plus/appsp/tree/master/sdk/flutter/aj_flutter_appsp