底层原理 - 深入探索Platform Channel

Flutter 提供了完善的 Platform Channel 机制供 Native 与 Flutter 端进行相互通信,本节由浅入深,详细探索 Platform Channel 的实现细节。

怎么用

首先我们简单介绍下 Platform Channel 的使用姿势,Flutter提供了三种Channel的方式:

  • BaseMessageChannel,消息的传递,用于数据的传递。
  • MethodChannel,方法的传递,用于方法调用,传递参数并返回执行结果。
  • EventChannel,事件的传递,用于Native端对Flutter端高频的通知触发。

MethodChannel - Android

构造函数,通常messenger传递FlutterNativeView实现的DartExecutor, name是Channel匹配的key值,codec是消息传递的编解码,默认是StandardMethodCodec。

1
MethodChannel(BinaryMessenger messenger, String name, MethodCodec codec)

调用Flutter端方法,参数依次是方法名、参数以及返回值的回调。

1
invokeMethod(String method, Object arguments, MethodChannel.Result callback)

设置方法被调用的处理器,Flutter调用原生代码会调用handler的onMethodCall方法。

1
setMethodCallHandler(MethodChannel.MethodCallHandler handler)

MethodChannel - iOS

构造函数

1
2
3
- (instancetype)initWithName:(NSString*)name
binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger
codec:(NSObject<FlutterMessageCodec>*)codec;

调用Flutter方法

1
2
3
- (void)invokeMethod:(NSString*)method
arguments:(id _Nullable)arguments
result:(FlutterResult _Nullable)callback

设置方法回调

1
- (void)setMethodCallHandler:(FlutterMethodCallHandler _Nullable)handler;

MethodChannel - Flutter

构造函数

1
- MethodChannel(this.name, [this.codec = const StandardMethodCodec()])

调用Native方法

1
- invokeMethod(String method, [dynamic arguments])

设置方法回调

1
- setMethodCallHandler(Future<dynamic> handler(MethodCall call))

BaseMessageChannel、EventChannel和MethodChannel的使用方式十分类似,这里就不一一赘述了。

怎么实现的

Platform Channel 以 engine 为媒介,在 native 端或者 dart 端进行消息的编码,经过 engine 传递,在另一端进行消息的解码,完成整个消息的通信。这个过程涉及到 messager (信使)、
codec(编解码器)以及 handler(处理器),下面将分别介绍。

messager

messager 在整个消息传递过程中,作为信使的角色,实现了完整的消息传递。我们看一下 Android 端 BinaryMessenger 的代码,iOS 和 Dart 部分 API 高度一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface BinaryMessenger {

void send(String channel, ByteBuffer message);

void send(String channel, ByteBuffer message, BinaryReply callback);

void setMessageHandler(String channel, BinaryMessageHandler handler);

interface BinaryMessageHandler {

void onMessage(ByteBuffer message, BinaryReply reply);
}

interface BinaryReply {

void reply(ByteBuffer reply);
}
}

BinaryMessenger 提供了两个 send 方法,可选择是否回调消息的处理结果,提供了 setMessageHandler 用以处理 Dart 端 Channel 调用的消息处理。BinaryMessenger 的一个具体实现在 DartMessenger,维护了 messageHandlers 和 pendingReplies 两个 HashMap 分别处理 Dart 端消息请求的响应以及自己发送消息之后的回调。具体的消息传递的完成流程我们等会再讲,先分析一下 codec(编解码器)和 handler(处理器)的实现细节。

codec

让消息在 dart、engine 以及平台( Android / iOS )之间自由传递,需要将消息在传递前编码为平台无关的二进制数据,在接收后解码为原始的数据格式。Flutter 提供了两种 codec , BaseMessageChannel 使用了 MessageCodec , EventChannel 和 MethodChannel 使用了 MethodCodec。

MessageCodec 提供了 encodeMessage 和 decodeMessage 两个接口,以 Android 为例,它的代码实现如下:

1
2
3
4
5
6
public interface MessageCodec<T> {

ByteBuffer encodeMessage(T message);

T decodeMessage(ByteBuffer message);
}

Flutter 官方默认已经实现的 MessageCodec 有 StandardMessageCodec、BinaryCodec、StringCodec、JSONMessageCodec 。默认已经实现的 MethodCodec 有 StandardMethodCodec、JSONMethodCodec。

Codec Desc
MessageCodec StandardMessageCodec 支持基础数据格式与二进制之间的转换,包括 Boolean 、Number、String、Byte、List、Map、数组等。
BinaryCodec 支持二进制数据的直接传递,实际上是没有做任何编解码,可以通过它做大内存块的数据传递。
StringCodec 支持字符串(UTF-8)与二进制之间的转换。
JSONMessageCodec 支持把数据转换为JSON,再调用 StringCodec 的编解码。
MethodCodec StandardMethodCodec 调用 StandardMessageCodec 传递方法的名字和参数。
JSONMethodCodec 调用 JSONMessageCodec 传递方法的名字和参数。
消息的编解码方式可以根据自己的需要从系统提供的 Codec 中选取,也可以完全自定义自己的 Codec 满足实际的业务需求。这里想实现一种基于 Protocol Buffers 协议的编解码器,作为一种简单的学习锻炼,后续会发布在 [Github](https://github.com/flutterboom/flutter-triple-sample) 上供大家参考。

Handler

Flutter 提供了 Handler 作为消息的处理器,用来处理接收方获取的消息。针对 BaseMessageChannel、MethodChannel 和 EventChannel 三种 Channel 分别提供了 IncomingMessageHandler、IncomingMethodCallHandler 和 IncomingStreamRequestHandler 三种 Handler,实际上是分别包装了 MessageHandler 、MethodCallHandler 和 StreamHandler 来处理对方发送过来的消息。

MessageHandler

1
2
3
4
5
6
7
8
9
public interface MessageHandler<T> {

void onMessage(T message, Reply<T> reply);
}

public interface Reply<T> {

void reply(T reply);
}

onMessage 接收获取的 message ,并提供回调 reply 。

MethodCallHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface MethodCallHandler {

void onMethodCall(MethodCall call, Result result);
}

public interface Result {

void success(@Nullable Object result);

void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails);

void notImplemented();
}

onMethodCall 响应调用的函数,并提供函数的返回值 Result。

StreamHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface StreamHandler {

void onListen(Object arguments, EventSink events);

void onCancel(Object arguments);
}

public interface EventSink {

void success(Object event);

void error(String errorCode, String errorMessage, Object errorDetails);

void endOfStream();
}

onListen 开启监听并发送数据,onCancel 取消监听。

消息传递过程

分析 Platform Channel 一个消息的完整调用路径:

native -> dart

从 DartMessenger 的 send 方法开始,
Java 通过 JNI 、iOS 直接调用 C/C++代码,
把消息通过 dispatchPlatformMessage 方法传递到 engine 层的 PlatformView 中,
然后分别经过 engine 、RuntimeController 最后调用 window 的 dispatchPlatformMessage 方法。
window 会自增一个 response_id 并创建一个 response 保存在 pending_responses_ 队列中,通过 tonic 库中的 DartInvokeField 方法调用 Dart 端的 _dispatchPlatformMessage,
完成了一个消息从 native 到 dart 的传递,
dart 端调用 window 的 onPlatformMessage 方法,
通过 channel name 匹配出已经注册好的 handler ,
执行后 通过 window 的 respondToPlatformMessage 方法回调到 engine 层的 RespondToPlatformMessage 方法。
engine 根据 response_id 在 pending_responses
取出对应的 response 执行 Complete 方法把结果返回给 Native 端,
Native 端在 DartMessenger 中的 handlePlatformMessageResponse 方法中处理具体返回的结果,完成了一整个的消息传递过程。

dart -> native

完整的过程跟 native 到 dart 的逻辑基本一致,除了一些函数调用上的差别,文字描述比较费力,这边是简单的做了一个图比较直观的看到整个消息的具体流向。

Flutter Support Native

在整个 Platform Channel 的实现细节梳理的过程中,我们不仅学习了一个数据通信的完整架构,同时也看到了大量的多种语言之间的相互调用。消息可以在不同的语言中自由的流转,主要是各种语言都实现了到 C/C++的函数调用的框架,Java 和 OC 已经有很成熟的支持 Native 的方案,Dart 也提供对 C/C++ 代码的调用的技术,具体的用法如下:

Dart 调用 C/C++ 代码

Dart 对 C/C++ 的支持目前还并不完善,社区中吐槽 Dart 对 Native 支持进展缓慢的帖子很多,主要的嘈点是目前官方对于 Native 的支持文章 Native extensions for the standalone Dart VM 还是 12 年 5 月份撰写的, 笔者也是根据这篇文章的指南调通了 Dart 对 C++ 的调用。在C++中实现了一个简单的加法运算,打包成 lib 提供给 Dart 调用。部分主要代码如下,完整工程可以在 Github 上查看,欢迎 Star 。

native_plus.cc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//第一次调用给定名称的本地函数时,将本地函数的 Dart 名称解析为 C 函数指针。
Dart_NativeFunction ResolveName(Dart_Handle name,
int argc,
bool* auto_setup_scope);

//在 lib 被加载时被调用。
DART_EXPORT Dart_Handle native_plus_Init(Dart_Handle parent_library) ;

// 实现了一个加法运算
void NativePlus(Dart_NativeArguments args) {
Dart_EnterScope();
int64_t result = 0;
Dart_Handle plus_a = HandleError(Dart_GetNativeArgument(args, 0));
Dart_Handle plus_b = HandleError(Dart_GetNativeArgument(args, 1));
if (Dart_IsInteger(plus_a) && Dart_IsInteger(plus_b)) {
int64_t a,b;
HandleError(Dart_IntegerToInt64(plus_a, &a));
HandleError(Dart_IntegerToInt64(plus_b, &b));
result = a + b;
}
Dart_SetReturnValue(args, HandleError(Dart_NewInteger(result)));
Dart_ExitScope();
}

//注册的方法列表
FunctionLookup function_list[] = {
{"NativePlus", NativePlus},
{NULL, NULL}};

C++代码可以根据不同平台 build 对应的链接库,我这边测试了在 MacOS上的导出,具体步骤如下:

使用 Xcode 创建本地扩展 dylib 项目:
1、选择新建类型:File -> New -> Project -> macOS -> Library
2、填写项目选项:
- Project Name:native_plus
- Framework:None(Plain C/C++ Library)
- Type:Dynamic
3、添加 native_plus.cc 文件到项目中。
4、在 Build Settings 中进行以下更改,在对话框中选择 Build 选项卡和 All Configurations :
- 在 Linking 部分, Other Linker Flags 项中,增加 -undefined dynamic_lookup 。
- 在 Search Paths 部分, Header Search Paths 项中,增加 dart_api.h 文件路径,在文件位于已下载的 SDK 或 Dart 仓库中。
- 在 Preprocessing 部分, Preprocessor Macros 项中,增加 DART_SHARED_LIB=1 。
5、Build。

test_native_plus.dart

1
2
3
4
5
6
7
//导入本地库,将 Build 生成的 libnative_plus.dylib 放置在同目录下
import 'dart-ext:native_plus';
//用 Native 修饰本地库的函数
int nativePlus(int a, int b) native "NativePlus";
void main() {
print(nativePlus(3, 5));
}

另外还可以使用 [C & C++ interop using FFI] (https://dart.dev/server/c-interop) 对已经开发好的 C/C++ 链接库直接调用。

Tonic

Dart 对 Native 的支持目前还不是很完善,Flutter 团队目前开发的 Tonic 库很好的去支持 Dart 与 C/C++ 代码的相互调用。Tonic 的代码仓库地址在这里
A collection of C++ utilities for working with the DartVM API. 通过 Tonic 可以完整实现 Dart 与 C++ 之间的代码交互。

总结

本文详细梳理了 Platform Channel 完整的实现细节,最大的收获是对数据通信整体架构的设计和 Dart 与 C/C++ 之间代码的相互调用实现。行文比较仓促,作为一个 Android Coder ,部分案例也是基于 Android 的角度分析代码,可能对于 iOS 同学有一些不太友好,还望海涵。文中涉及到的任何内容,欢迎大家留言讨论。

Maven 入门笔记

前言

后端开发领域非常庞大,知识体系涉及非常广泛,作为一个安卓转后端开发的新人,需要学习的内容非常多,像数据库、缓存、消息队列、微服务架构、网络通信、RPC、JAVA 语言等等。而学习一个领域最好的方式就是把这块内容整理消化,成为自己知识图谱中的一部分。未来计划是将这些领域的基础知识点都能归纳总结,今天就迈出了第一步,将就 JAVA 开发中非常基础也很重要的构建工具 - Maven 做一些整理归纳,一方面留给自己勘查校验使用,另一方面也希望对 JAVA 新手有一些借鉴意义。

1、Maven 是什么?

对于一个安卓开发的同学来说,Maven 可能更多的意味着一个 Maven 仓库,用来管理第三方 Library 的生成物,实际上 Maven 是一个主要用于 Java 项目的构建管理工具。

We wanted a standard way to build the projects, a clear definition of what the project consisted of, an easy way to publish project information and a way to share JARs across several projects.

从官网的介绍中我们大概可以看到 Maven 包含以下几个主要的功能:

  • 标准的构建工具
  • 清晰的项目定义
  • 便易的项目发布
  • 轻松的依赖管理

2、两个重要配置文件

2.1 settings.xml

对于 Maven 的全局配置是通过 settings.xml 文件来进行管理的,settings 文件的配置包含两个作用域( 用户域会高于版本域,当两个路径都有配置且存在相同配置项的时候,用户域优先 ):

含义 位置 作用域
用户级 %USER_HOME%/.m2/settings.xml 当前用户
版本级 %M2_HOME%/conf/settings.xml 当前版本

settings.xml 主要配置如下:

属性 含义
localRepository 本地仓库地址,默认是${user.home}/.m2/repository
interactiveMode 是否交互式创建Maven项目,默认true。
usePluginRegistry 是否需要使用plugin-registry.xml文件来管理插件版本,默认false。
offline 是否是离线模式下构建,默认false。
proxies 代理配置,科学上网
servers 服务认证,配合pom.xml中distributionManagement设置,用于上传私服的用户验证
mirrors 仓库镜像配置
profiles 多环境配置,设置环境参数
activeProfiles 默认激活的环境配置
pluginGroups 默认的pluginGroupId

2.2 POM.xml

pom 的全称是项目对象模块(Project Object Model),它是一个 Maven 项目的核心,通过 xml 语言定义了一个项目的基本信息、依赖信息、构建参数等等。主要的配置参数如下:

2.2.1【基础信息】

指定了项目在 Maven 仓库中的唯一坐标:

参数 含义
groupId 项目名称
artifactId 项目中细分的模块名称
version 版本号
packaging 打包方式,包含jar、pom、war等
2.2.2【依赖信息】

通过 dependencies 标签描述了项目依赖的其他项目,其中 groupId, artifactId, version 是必须的,唯一指定了一个项目的坐标,另外一个关键的参数是 scope ,指定了依赖的作用范围:

scope 作用
compile 默认值,一个比较强的依赖,参与编译、测试、打包
provided 参与编译,不参与打包
runtime 不参与编译,直接入包
test 只在测试时有效

通过 dependencyManagement 标签可以统一管理依赖的版本号,一般的,在父 POM 中的 dependencyManagement 声明用到的依赖及其版本,则可以在子 POM 中只引用 groupId 和 artifactId 并继承 version ,达到统一管理的目的。

2.2.3【聚合继承】

现在的大型项目基本上都采用了多模块的架构方式,而 Maven 通过聚合继承了的方式很好的解决了多模块构建的问题。

在 modules 标签中添加管理的子模块,Maven 在构建的就会根据模块之间的依赖关系按照顺序依次构建,避免了多模块开发中每个模块都需要单独构建的浪费。

在 parent 标签中指定了父 POM 的地址,实际上子 POM 通过继承获取的绝大部分父 POM 的配置,Maven 通过这种继承的方式,大大简化了子 POM 的配置,同时将一些公共的配置放在父 POM 中管理,更有利于维护大型项目的规范性。如果没有指定 parent ,则默认系统的 pom.xml 这个超级 POM 作为父 POM, 实际上超级 POM 已经定义了很多默认参数作为最佳实践,简化了 POM 的配置。

2.2.4【构建参数】

通过 build 元素描述构建参数,pom.xml 中有两种 build 配置,一种是在 project 根标签下,称之为 Project Build,一种是在 profile 标签下,称之为 Profile Build,主要用于多环境配置中的构建配置,在我们的项目中使用比较少,下面主要介绍 Project Build 的主要配置:

2.2.4.1 基础信息

主要描述跟构建相关的信息,比如源码路径、目标文件名、目标文件路径、

  • defaultGoal,执行构建时默认的goal或phase
  • directory, build 目标文件的存放目录,默认在 {basedir}/target 目录
  • outputDirectory, build 源码目标文件的存放目录,默认在 {basedir}/target/classes 目录
  • sourceDirectory, 源码目录
  • finalName, build 目标文件的文件名,默认情况下为 {artifactId}-{version}
2.2.4.2 resources 标签

描述了项目中运行或测试相关的所有资源参数

2.2.4.3 plugins 标签

Maven 的每一个构建任务的实际实现都是通过 plugin 完成的,Maven 提供了一个可执行的环境,具体的构建任务是在各种 plugin 中定义的,比如用于编译的 compile 插件,用户打包的 jar 插件等等。Maven 已经提供了大量默认 plugin 涵盖项目的编译、测试、打包、部署等方方面面。目前 Maven 官方有两个插件列表,第一个列表的 GroupId 为 org.apache.maven.plugins,这里的插件最为成熟 。第二个列表的 GroupId 为 org.codehaus.mojo,这里的插件没有那么核心,但也有不少十分有用。项目中经常用到的一些插件包括:

  • maven-clean-plugin
  • maven-compiler-plugin
  • maven-deploy-plugin
  • maven-install-plugin
  • maven-antrun-plugin
  • maven-archetype-plugin
  • maven-assembly-plugin
  • maven-dependency-plugin
  • maven-enforcer-plugin
  • maven-help-plugin
  • maven-release-plugin
  • maven-resources-plugin
  • maven-surefire-plugin
  • build-helper-maven-plugin
  • exec-maven-plugin
  • jetty-maven-plugin
  • versions-maven-plugin
2.2.5【环境参数】

通过 scm 标签配置项目的代码仓库配置,这个一般在公司项目中很少使用。
通过 distributionManagement 标签,可以指定 Maven 上传的地址,一般会定义 release 和 snapshot 两个仓库,同时配合 Setting.xml 中 servers 的用户登录配置,完成私仓的上传校验。
通过 profiles 标签定义了多环境下的参数配置,一般用于区分开发环境、测试环境和线上环境等。

3、生命周期

3.1 深入理解 lifecycle、phase、goal、plugin

Maven 抽象了一个生命周期(lifecycle)的概念,把一个Java项目的构建过程抽象为三种场景:清除(clean)、构建(default)、部署(site)。生命周期是构建过程的统一和抽象,没有具体执行任务。一个场景的生命周期中被划分为多个阶段(phase),而每个阶段又包含着一个或者多个目标(goal),而每个目标才是真正要实现的操作,这部分实现是在插件(plugin)中完成的。

实际上 Maven 打造了两套系统,一个是通过定义【生命周期】、【阶段】、【目标】这三层结构创建了一个可执行的环境,将 Java 项目中的不同场景进行了覆盖。另一个是通过定义一套【插件】系统,去具体完成一些构建目标。然后通过一种绑定机制,非常有效的将插件运行在 Maven 的执行环境中。实际上看这两套系统的实现非常契合我们常见的模版模式的一种设计方式,也让我们的插件系统有了更好的拓展性。

将 Maven 插件目标和生命周期阶段进行绑定可以在执行某一阶段的构建时运行插件的目标,目前 Maven 已经提供了以下一些内置的标准绑定:

生命周期 阶段 插件目标
clean clean maven-clean-plugin:clean
default process-resources maven-resources-plugin:resources
compile maven-compiler-plugin:compile
process-test-resources maven-resources-plugin:testResources
test-compile maven-compiler-plugin:testCompile
test maven-surefire-plugin:test
package maven-jar-plugin:jar
install maven-install-plugin:install
deploy maven-deploy-plugin:deploy
site site maven-site-plugin:site
site-deploy maven-site-plugin:deploy

除了内置的绑定关系外,Maven还提供了一种自定义的绑定方式,不过这里有一点局限的是绑定的阶段必须是 Maven 已经预置的阶段(phase),可以在 POM 的 executions 标签中进行配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
<plugin>
<artifactId>maven-source-plugin</artifactId>
<version>3.0.1</version>
<executions>
<execution>
<id>attach-sources</id>
<phase>compile</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>

3.2、mvn 命令行

Maven 提供了 mvn 的命令行工具,用于项目的构建执行。

1
mvn [options] [<goal(s)>] [<phase(s)>]

mvn 实际上提供了两种构建的方式,一种是执行指定的goal(s),一种是执行phase(s)。不同的是,执行 phase 的时候会先执行依赖的 phase ,而执行 goal 仅仅执行自己,需要已经存在前置依赖的环境,否则失败。两种调用方式以 install 举例说明:

1
2
mvn install  ( phase 方式)
mvn org.apache.maven.plugins:maven-install-plugin:2.4:install ( goal 方式)

前面我们提到,我们把插件目标和生命周期阶段绑定之后,实际上我们执行 mvn [<phase(s)>] 就是执行一个有序的 goals 列表。当然我们看到,上面这种执行 goal 的方式命令太长,参数比较繁琐,实际上它有几个简化的版本:

首先版本号如果没有特殊指定 Maven 会到对应插件的 maven-metadata.xml 文件中找到 release 版本进行使用,所以命令可以简化为:

1
mvn org.apache.maven.plugins:maven-install-plugin:install

另外 Maven 默认以 org.apache.maven.plugins 作为 groupId ,我们可以在 settings.xml 文件中通过 pluginGroups 标签指定默认的 groupId ,所以我们可以省略一长串的 groupId ,去掉 groupId 之后,我们还可以使用 plugin 的缩略名,这个缩略名定义在 plugins 仓库的 maven-metadata.xml 文件中。 maven-install-plugin 插件的缩略名为 install ,所以我们的命令最终可以简写为:

1
mvn install:install

4、依赖管理

4.1 依赖传递

Maven 提供了一种隐性依赖的方式将间接依赖的三方库像直接依赖一样,举例来说,项目 A 依赖了项目 B ,项目 B 依赖了项目 C ,那项目 A 就像直接依赖了项目 C 一样使用项目 C 对外暴露的功能。不过在一个大型的项目管理中,错综复杂的依赖关系会导致依赖传递过程中出现的各种依赖冲突。一般地,我们可以通过 dependencyManagement 在根项目中指定项目的版本,如果没有指定,Maven 将按照默认提供的依赖仲裁法案对于版本号不一致的项目进行科学仲裁,这跟 Gradle 中默认选择最高版本的做法不太一致,这种仲裁有两个原则:
一、就近原则,根据项目依赖深度选择最近依赖的版本;

A -> B -> C 2.0
A -> C 1.0
通过就近原则,最终 A 依赖 C 的是 1.0 的版本。

二、优先声明原则,如果路径深度一致,则选择最先声明的版本。

A -> B -> C 2.0
A -> D -> C 1.0
通过优先声明原则,最终 A 依赖 C 的是 2.0 的版本。

另外,通过 exclusion 标签可以把不需要的项目版本排除依赖,直接指定特定的版本来解决依赖冲突。

4.2 依赖带来的问题

  • java.lang.ClassNotFoundException

  • java.lang.NoSuchMethodException

一般的,当我们遇到这两类错误的时候,第一反应就是引用的jar包版本不对,一方面有可能是使用的三方jar包与线上环境不一致,导致运行时无法找到类或者方法而抛错,另一方面,可能更常见的是由于多方依赖导致的依赖冲突,由于 Maven 仲裁只能选择一个版本,导致另外一个版本所使用的API存在 NotFound 的风险。

4.3 冲突解决

在 Intellij idea 上可以非常方便的查看一个项目的依赖关系,总体来说有以下几个方式:

1)通过查看 pom 文件的 Diagrams ,在 IDE 上会生成项目的依赖关系 UML 图,图中不仅以树状的结构清晰的展示了项目直接依赖和间接依赖的所有项目,同时对于有依赖版本冲突的项目通过红线标注,可以很方便的使用 exclusion 排除进行冲突解决。

2)安装 Maven Helper 插件,可以对于每个 pom.xml 文件提供 Dependency Analyzer 功能,对于版本冲突的依赖会高亮显示,右击 Exclude 可排除依赖。

3)IDE 中 Maven 侧边栏中通过层级结构清晰的显示了依赖关系,对于冲突的版本通过灰色进行了说明。

4) mvn dependency:tree 命令可以打印出当前项目的依赖树,不过总体上没有以上三种直观。

5、自定义插件

除系统提供的 Maven 插件外,我们还可以根据自己业务的特定需求,自定义符合自己需要的 Maven 插件,比较熟悉的像 MyBatis 插件,就是帮助我们自动生成数据库相关的交互代码,通过定义插件,一方面可以简化我们某些特定的代码编写,另外一方面也可以将我们的某部分代码进行规范,减少手写误操作引入的bug 。

自定义插件的方式非常简单,主要是继承 AbstractMojo 类并实现 execute 方法,这方面的资料在网上也比较多,最主要的是大家应该知道在什么场景下需要实现自定义的插件。这里就我们现实中的一个需求,编写了一个简单的插件来说明自定义插件的意义。

代码地址在: Github:panda-maven

这个插件的使用场景是对我们需要的 service 对外暴露生成一个 interface ,因为在我们公司提供的 SOA 服务中,是以 interface 进行注册的,如果我们想对一些特定的 service 对外暴露,就需要手写一些 interface 及 impl,这些非常机械的工作当然是可以交给 Maven 插件来完成的。插件比较简单,仅仅作为自己学习 Maven 练手使用,如果大家有类似的需求,可以在此基础上进行优化完善。

6、结语:约定优于配置

实际上,Maven 一直提倡的是简约风。将大家约定俗成的规范设置为默认配置,提供最佳实践作为 Maven 使用规范,使得我们在日常的使用过程中只需要对 Maven 进行简单的配置就可以完成绝大部分的工作。我们应该谨记 Maven 的这条经验,尽量使用默认提供的配置选项,减少奇奇怪怪的特定配置。

Android Gradle Plugin源码解析

笔者最近在做公司项目的模块化重构,做的过程中一直在思考以下几个问题:

  • 一个apk文件和一个aar文件有什么区别?
  • 什么样的工程会导出一个apk,什么样的工程可以导出aar?
  • 一个apk的诞生伴随着哪些配置的过程,aar呢?
  • 他们俩之间可以快速的进行交换吗?

以上的这些疑问都在Google大大给我们开发的两个plugin中得到答案:

  • com.android.application
  • com.android.library

这是我们开发安卓应用时最常用的两个plugin,作为一个Android开发者,怎么可能不对它的实现不感兴趣呢,所以接下来我将用两到三个博客的内容,谈一谈读Android Gradle Plugin源码的一些心得。今天主要讲一些基础的部分。

源码下载方式

Android Gradle Plugin 一个版本的源码大概有30多个G,如果你的磁盘资源充足,可以使用repo的方式下载到本地,下面是2.3.0分支的一个示例:

1
2
3
4
$ mkdir gradle_2.3.0
$ cd gradle_2.3.0
$ repo init -u https://android.googlesource.com/platform/manifest -b gradle_2.3.0
$ repo sync

如果你没有翻墙的工具,可以使用国内的一些镜像:

repo的初始化可以参照 Google 教程 https://source.android.com/source/downloading.html

下载后的源码用IntelliJ IDEA打开tools的base路径,目录结构如下:
项目结构

主要代码在红框内的三个module中。在看Android Gradle Plugin的源码之前,我们先简单的看一下一个自定义的Gradle Plugin是如何实现的。

Gradle plugin简介

关于自定义一个 Gradle Plugin 的教程很多,我们简单的做一个说明。使用 gradle init 命令可以在当前目录下新建一个简单的gradle工程,目录结构如下:

gradle工程

这是一个基于Gradle Wrapper的多工程Gradle项目。在settings.gradle中可以配置子项目的路径,像我们在Android项目中经常配置的:

1
2
include ':app'
include ':lib'

说到这里可以多说一句在模块化开发中的经验,我们可以通过指定subproject的路径的方式,可以将本地任何路径下的代码导入工程中来,方便我们进行本地调试:

1
2
include ':lib'
project(':lib').projectDir = new File('xx/xx/xx/lib')

在rootproject的build.gradle文件中创建一个最简单的gradle plugin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class GreetingPluginExtension {
String message
String greeter
}

class GreetingPlugin implements Plugin<Project> {
void apply(Project project) {
def extension = project.extensions.create('greeting', GreetingPluginExtension)
project.task('hello') {
doLast {
println "${extension.message} from ${extension.greeter}"
}
}
}
}


apply plugin: GreetingPlugin

greeting {
message = 'Hi'
greeter = 'Gradle'
}

这段代码中,我们定义了一个GreetingPlugin,他新增了一个名为 ‘hello’ 的task,在终端输出一行信息,这个信息可以通过 GreetingPluginExtension 进行配置,我们执行一下:

1
2
3
4
5
6
7
8
9
./gradlew hello
Starting a Gradle Daemon (subsequent builds will be faster)
Parallel execution with configuration on demand is an incubating feature.
:hello
Hi from Gradle

BUILD SUCCESSFUL

Total time: 4.0 secs

其实也可以看出自定义一个plugin主要就是新增 Task 及所需参数进行配置的 Extension。Android Gradle Plugin定义了很多task,其中我们最常用的包括 clean build assemble 等,还有更多这些task运行时依赖的task,涉及到安卓编译打包的各个方面,我们在下一个博客中再具体阐述。今天主要Android Plugin的Extension的部分实现,这也是我们日常配置一个Android工程最主要的工作。

Extension机制

如何理解 Gradle 的 Extension,这涉及到Groovy的闭包委托特性。Groovy的闭包有this、owner、delegate三个属性,当你在闭包内调用方法时,由他们来确定使用哪个对象来处理。有关闭包的详情可以查看 Groovy Closures。利用Groovy的闭包委托特性,我们可以简单的实现Extension:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Person {
String personName = '李四'
int personAge = 18
def printPerson(){
println "name is ${personName},age is ${personAge}"
}
}
def person(Closure<Person> closure){
Person p = new Person();
closure.delegate = p //委托模式优先
closure.setResolveStrategy(Closure.DELEGATE_FIRST);
return closure
}

def closure = person {
personName = '张三'
personAge = 20
printPerson()
}

task configClosure {
doLast {
closure()
}
}

首先我们定义一个Person对象,然后定义一个person的方法,它接受一个闭包作为参数,修改闭包委托模式优先,执行person方法,定义一个task执行这个闭包,运行结果如下:

1
2
3
4
5
6
7
8
/gradlew configClosure
Parallel execution with configuration on demand is an incubating feature.
:configClosure
name is 张三,age is 20

BUILD SUCCESSFUL

Total time: 0.981 secs

Gradle中大量使用了Extension这一特性,引用gradle一句原话:

Many Gradle objects are extension aware. This includes; projects, tasks, configurations, dependencies etc.

安卓插件使用的Extension都继承自BaseExtension:

1
2
3
4
5
6
7
* <ul>
* <li>Plugin <code>com.android.application</code> uses {@link AppExtension}
* <li>Plugin <code>com.android.library</code> uses {@link LibraryExtension}
* <li>Plugin <code>com.android.test</code> uses {@link TestExtension}
* <li>Plugin <code>com.android.atom</code> uses {@link AtomExtension}
* <li>Plugin <code>com.android.instantapp</code> uses {@link InstantAppExtension}
* </ul>

com.android.application插件使用的是AppExtension,com.android.library插件使用的是LibraryExtension。下面分别讲一下两个Extension的详细配置。

AppExtension

以下是AppExtension的所有配置,我按照使用频率进行一个简单的介绍,第一行是官方对于属性的介绍,我会针对每个属性做一些使用上的说明。

applicationVariants

The list of Application variants. Since the collections is built after evaluation, it should be used with Gradle’s all iterator to process future items.

applicationVariants是AppExtension继承自BaseExtension唯一拓展的成员变量,它的参数类型是DefaultDomainObjectSet,这是不同buildType及Flavor的集合,
applicationVariants最常用的是它的all方法,例如一个简单的修改apk名字的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def buildTime() {
return new Date().format("yyyy-MM-dd", TimeZone.getTimeZone("UTC"))
}
android {
applicationVariants.all { variant ->
variant.outputs.each { output ->
def outputFile = output.outputFile
if (outputFile != null && outputFile.name.endsWith('.apk')) {
def fileName = "${variant.buildType.name}-${variant.versionName}-${buildTime()}.apk"
output.outputFile = new File(output.outputFile.parent,fileName)
}
}
}
}

buildToolsVersion

Required. Version of the build tools to use.

defaultConfig

Default config, shared by all flavors.

所有Flavor的默认设置,它是一个 ProductFlavor 对象,可以做以下设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
defaultConfig {
applicationId '**.**.**' //The application ID.

applicationIdSuffix '.two' //applicationId的后缀,可以用在想同时安装运行两个Flavor包的时候,比如同时安装debug包和Release包做一些对比。

minSdkVersion 14

targetSdkVersion 25

versionCode 1

versionName "1.0"

versionNameSuffix ".0" // versionName后缀

consumerProguardFiles 'proguard-rules.pro' //用于Library中,可以将混淆文件输出到aar中,供Application混淆时使用。

dimension 'api' //给渠道一个分组加维度的概念,比如你现在有三个渠道包,分成免费和收费两种类型,可以添加一个dimension, 打渠道包的时候会自动打出6个包,而不需要添加6个渠道,详细的说明可见 https://developer.android.com/studio/build/build-variants.html#flavor-dimensions。

externalNativeBuild { //ndk的配置,AS2.2之后推荐切换到cmake的方式进行编译。
cmake {
cppFlags "-frtti -fexceptions"
arguments "-DANDROID_ARM_NEON=TRUE"
buildStagingDirectory "./outputs/cmake"
path "CMakeLists.txt"
version "3.7.1"
}
ndkBuild {
path "Android.mk"
buildStagingDirectory "./outputs/ndk-build"
}
}

javaCompileOptions {
annotationProcessorOptions { //注解的配置。
includeCompileClasspath true //需要使用注解功能。
arguments = [ eventBusIndex : 'org.greenrobot.eventbusperf.MyEventBusIndex' ] //AbstractProcessor中可以读取到该参数。
classNames
}
}

manifestPlaceholders = [key:'value'] //manifest占位符 定义参数给manifest调用,如不同的渠道id。

multiDexEnabled true //开启 multiDex

multiDexKeepFile file('multiDexKeep.txt') //手动拆包,将具体的类放在主DEX。

multiDexKeepProguard file('multiDexKeep.pro') //支持Proguard语法,进行一些模糊匹配。

ndk {
abiFilters 'x86', 'x86_64', 'armeabi' //只保留特定的api输出到apk文件中。
}

proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' //混淆文件的列表,如默认的android混淆文件及本地proguard文件,
//切记不要遗漏android混淆文件,否则会导致一些默认的安卓组件无法找到。

signingConfig {
//签名文件的路径
storeFile file('debug.keystore')
//签名文件密码
storePassword 'android'
//别名
keyAlias 'androiddebygkey'
//key的密码
keyPassword 'android'
}

buildConfigField('boolean','IS_RELEASE','false') //代码中可以通过BuildConfig.IS_RELEASE 调用。

resValue('string','appname','demo') //在res/value 中添加<string name="appname" translatable="false">demo</string>。

resConfigs "cn", "hdpi" //指定特定资源,可以结合productFlavors实现不同渠道的最小的apk包。
}

productFlavors

All product flavors used by this project.
渠道包的列表,可以覆盖defaultConfig的参数配置,形成自己的风味

flavorDimensionList

The names of flavor dimensions.
添加维度的定义,维度的使用上面defaultConfig已经有说明了。

resourcePrefix

A prefix to be used when creating new resources. Used by Android Studio.
在模块化开发中比较重要,给每个模块指定一个特定的资源前缀,可以避免多模块使用相同的文件命名后合并冲突,在build.gradle中指定了这个配置后,AS会检查不合法的资源命名并报错。

buildTypes

Build types used by this project.

buildType的列表,默认有release和debug,可以自己自定义不同的buildtype,相应的构建task name是 assemble+buildTypeName, buildType部分配置和defaultConfig相同,不同配置使用说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
debug {

applicationIdSuffix '.debug' //同defaultConfig

versionNameSuffix '.1' //同defaultConfig

debuggable true //生成的apk是否可以调试 debug默认是true release默认false

jniDebuggable true //是否可以调试NDK代码 使用lldb进行c和c++代码调试

crunchPngs true //是否开启png优化,会对png图片做一次最优压缩,影响编译速度,debug默认是false release默认true

embedMicroApp true //Android Wear的支持

minifyEnabled true //是否开启混淆

renderscriptDebuggable false //是否开启渲染脚本

renderscriptOptimLevel 5 //渲染脚本等级 默认是5

zipAlignEnabled true //是否zip对齐优化 默认就是true app对齐
}

ndkDirectory

The NDK directory used.
NDK路径,也可以在local.properties 中配置 ndk.dir=/Users/xxxx/Library/Android/sdk

sdkDirectory

The SDK directory used.
同ndkDirectory,目前一般配置在local.properties。

aaptOptions

Options for aapt, tool for packaging resources.
aapt是一个资源打包工具,可以对资源优化做一些动态配置:

1
2
3
4
5
6
7
8
9
10
11
aaptOptions{
additionalParameters '--rename-manifest-package',
'cct.cn.gradle.lsn13','-S','src/main/res2','--auto-add-overlay' //aapt执行时的额外参数

cruncherEnabled true //对png进行优化检查

ignoreAssets '*.jpg' //对res目录下的资源文件进行排除 把res文件夹下面的所有.jpg格式的文件打包到apk中

noCompress '.jpg' //对所有.jpg文件不进行压缩

}

adbExecutable

The adb executable from the compile SDK.
adb工具的文件路径,可以配置在环境变量中。

adbOptions

Adb options.
adb命令的一些配置

1
2
3
4
5
adbOptions   {
installOptions '-r' '-d' //调用adb install命令时默认传递的参数

timeOutInMs 1000 //执行adb命令的超时时间
}

compileOptions

Compile options.
编译配置

1
2
3
4
5
6
7
8
9
10
compileOptions{
encoding 'UTF-8' //java源文件的编码格式 默认UTF-8


incremental true //java编译是否使用gradle新的增量模式

sourceCompatibility JavaVersion.VERSION_1_7 //java源文件编译的jdk版本

targetCompatibility JavaVersion.VERSION_1_7 //编译出的class的版本
}

dataBinding

Data Binding options.
DataBinding的使用细节可以查看 Google文档,可以在build.gradle中开启DataBinding:

1
2
3
4
5
dataBinding  {
enabled = true //开启databinding
version = "1.0"
addDefaultAdapters = true
}

defaultPublishConfig

Name of the configuration used to build the default artifact of this project.
指定发布的渠道及BuildType类型。在Library中使用,默认Release。

signingConfigs

Signing configs used by this project.
一个签名配置的列表,可以供不同渠道和buildType使用。

lintOptions

Lint options.
Lint可以检查出代码中一些不规范的使用,如果想保留一些苟且的代码,可以参考以下配置(简友lyzaijs同学对这一块有详细说明,下面引用自他的博客):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
lintOptions { 
quiet true // 设置为 true时lint将不报告分析的进度

abortOnError false // 如果为 true,则当lint发现错误时停止 gradle构建

ignoreWarnings true // 如果为 true,则只报告错误

absolutePaths true // 如果为 true,则当有错误时会显示文件的全路径或绝对路径

checkAllWarnings true // 如果为 true,则检查所有的问题,包括默认不检查问题

warningsAsErrors true // 如果为 true,则将所有警告视为错误

disable 'TypographyFractions','TypographyQuotes' // 不检查给定的问题id

enable 'RtlHardcoded','RtlCompat', 'RtlEnabled' // 检查给定的问题 id

check 'NewApi', 'InlinedApi' // * 仅 * 检查给定的问题 id

noLines true // 如果为true,则在错误报告的输出中不包括源代码行

showAll true // 如果为 true,则对一个错误的问题显示它所在的所有地方,而不会截短列表,等等。

lintConfig file("default-lint.xml") // 重置 lint 配置(使用默认的严重性等设置)。

textReport true // 如果为 true,生成一个问题的纯文本报告(默认为false)

textOutput 'stdout' // 配置写入输出结果的位置;它可以是一个文件或 “stdout”(标准输出)

xmlReport false // 如果为真,会生成一个XML报告,以给Jenkins之类的使用

xmlOutput file("lint-report.xml") // 用于写入报告的文件(如果不指定,默认为lint-results.xml)

htmlReport true // 如果为真,会生成一个HTML报告(包括问题的解释,存在此问题的源码,等等)

htmlOutput file("lint-report.html") // 写入报告的路径,它是可选的(默认为构建目录下的 lint-results.html )

checkReleaseBuilds true // 设置为 true, 将使所有release 构建都以issus的严重性级别为fatal(severity=false)的设置来运行lint,并且,如果发现了致命(fatal)的问题,将会中止构建(由上面提到的 abortOnError 控制.

fatal 'NewApi', 'InlineApi' //设置给定问题的严重级别(severity)为fatal (这意味着他们将会在release构建的期间检查 (即使 lint 要检查的问题没有包含在代码中)

error 'Wakelock', 'TextViewEdits' // 设置给定问题的严重级别为error

warning 'ResourceAsColor' // 设置给定问题的严重级别为warning

ignore 'TypographyQuotes' // 设置给定问题的严重级别(severity)为ignore (和不检查这个问题一样)
}

}

###dexOptions
Dex options.
Android dx工具是将java的classes文件编译为字节码dex文件,工具位于android sdk platform-tools目录,我们在做热修复做差分包的时候可能会用到这个工具,在android打包过程中,dx的可以做以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dexOptions {
additionalParameters '--minimal-main-dex','--set-max-idx-number=10000' //dx命令附加参数

javaMaxHeapSize '2048m' //执行dx时java虚拟机可用的最大内存大小

jumboMode true //开启大模式,所有的class打到一个dex中,可以忽略65535方法数的限制,低于14版本不可运行

keepRuntimeAnnotatedClasses true //在dex中是否保留Runtime注解 默认是true

maxProcessCount 4 //默认dex中的进程数 默认是4

threadCount 4 //默认的线程数

preDexLibraries true //对library预编译 提高编译效率 但是clean的时候比较慢 默认开启的
}

packagingOptions

Packaging options.
打包配置,尤其是在一些多模块开发工程中,涉及到的一些资源合并取舍的策略:

1
2
3
4
5
6
7
8
packagingOptions   {

pickFirsts = ['META-INF/LICENSE'] //pickFirsts做用是 当有重复文件时 打包会报错 这样配置会使用第一个匹配的文件打包进入apk

merge 'META-INF/LICENSE' //重复文件会合并打包入apk

exclude 'META-INF/LICENSE' //打包时排除匹配文件
}

sourceSets

All source sets. Note that the Android plugin uses its own implementation of source sets, AndroidSourceSet.An AndroidSourceSet represents a logical group of Java, aidl and RenderScript sources as well as Android and non-Android (Java-style) resources.

所有Android资源的集合,包括Java代码,aidl以及RenderScript。默认配置如下,有一些自定义路径的情况下需要修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sourceSets{
main {
res.srcDirs 'src/main/res'
jniLibs.srcDirs = ['libs']
aidl.srcDirs 'src/main/aidl'
assets.srcDirs 'src/main/assets'
java.srcDirs 'src/main/java'
jni.srcDirs 'src/main/jni'
renderscript.srcDirs 'src/main/renderscript'
resources.srcDirs 'src/main/resources'
manifest.srcFile 'src/main/AndroidManifest.xml'
}

free { //除了main,也可以给不同的渠道指定不同的配置

}
}

splits

APK splits options.
这个特性非常有用,不过国内应用基本使用不上,可以根据CPU架构和屏幕像素密度打出最小的apk包,再配合Google Play的市场分发机制,让你可以下载到适合你使用的apk。有abi、density、language三个维度进行过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
splits  {
abi {
enable true //开启abi分包

universalApk true //是否创建一个包含所有有效动态库的apk

reset() //清空defaultConfig配置

include 'x86','armeabi' //打出包含的包 这个是和defaultConfig累加的

exclude 'mips' //排除指定的cpu架构
}

density {

enable true //开启density分包

reset() //清空所有默认值

include 'xhdpi','xxhdpi' //打出包含的包 这个是和默认值累加的

exclude 'mdpi' //排除指定
}

language {
enable true //开启language分包
include 'en','cn' // 指定语言
}
}

variantFilter

Callback to control which variants should be excluded.
上面我们通过flavor及buildType构建出大量的apk,这里可能有你不需要的,android plugin也考虑到这一点,可以动态设置忽略一些产出:

1
2
3
4
5
6
7
8
9
10
variantFilter { variant ->

def buildTypeName = variant.buildType.name
def flavorName = variant.flavors.name

if (flavorName.contains("360") && buildTypeName.contains("debug")) {
// Tells Gradle to ignore each variant that satisfies the conditions above.
setIgnore(true)
}
}

LibraryExtension

libraryVariants

The list of library variants. Since the collections is built after evaluation, it should be used with Gradle’s all iterator to process future items.

libraryVariants也有类似于applicationVariants的all闭包,可以获取到所有的应用回调,其中可以做一些特定的设置:

1
2
3
4
5
android.libraryVariants.all { variant ->
def mergedFlavor = variant.getMergedFlavor()
// Defines the value of a build variable you can use in the manifest.
mergedFlavor.manifestPlaceholders = [hostName:"www.example.com"]
}

以上是对AppExtension及LibraryExtension的一个详细说明,这个Android Plugin所有task运行的基础配置,下一节将关注我们常用的task实现以及其中的依赖关系。

参考文献

http://google.github.io/android-gradle-dsl/current/

https://docs.gradle.org/current/dsl/

Gson增量解析

增量解析是我自己杜撰的一个名词,主要是有这样的场景,移动端请求服务端json数据的时候,考虑到流量资源的珍贵,如果本地已经请求过完整下信息,一般是将一个model更新的字段下发,而不是每次都下发完整的model。举个例子,我们向服务器请求user的信息,完整的数据格式可能是:

{“age”:20,”id”:1,”name”:”张三”,”sex”:”男”}

User中的id、name、sex可能是一直不变的,而age是有可能发生变化的,当客户端刷新user的信息时,服务端只下发age的信息就行了,以达到节省流量的目的,下发的数据格式可能是:

{“age”:25,”id”:1}

只有age发生变化,所以只下发了age的信息,id作为识别字段。我们用Gson解析的时候一般是直接调用fromJson函数,这个函数接受两个参数,一个是json字符串,一个是解析的model类。对于这种只有部分数据的json我们如何解析呢,难道重新定义一个SubUser类,处理这种解析然后赋值给User吗,这样做的代价是非常不灵活,更新的字段必须客户端写死,如果下次name或者sex也发生了变化呢,是不是SubUser类就需要修改然后发版?

能不能有个类似于fromJson的函数,他的第二个参数不再是一个类,而是一个实例,新的json数据只会更新这个实例的某些字段。Gson在解析的时候会调用model的默认构造函数new出一个实例,能不能不让它new而是直接在原有的实例基础上进行解析呢?查看Gson的官方文档,看到可以给Gson注册一个InstanceCreator,它是一个接口,顾名思义它是提供用户一个初始化model的函数:

1
2
3
4
public interface InstanceCreator<T> {

public T createInstance(Type type);
}

这样我们就可以把原来的user作为初始化的实例,实现增量更新的解析。这是对于object的解析,可以这么做。如果是list呢,如何进行增量解析呢,总不能把原来的list作为返回值吧,目前我还没有很好的思路,只是将列表拆分成object分别进行解析。

下面简单的写一个demo:

User类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class User{

public long id;

public String name;

public String sex;

public int age;

public long getId() {
return id;
}
}

GsonCreatorHelper类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class GsonCreatorHelper {

public static User fromJson(String json, final User old) {
try {
JSONObject object = new JSONObject(json);
long id = object.getLong("id");
if (id == old.getId()) {
Gson gson = new GsonBuilder().registerTypeAdapter(User.class, new InstanceCreator<User>() {
@Override
public User createInstance(Type type) {
return old;
}
}).create();
return gson.fromJson(json, User.class);
}
} catch (JSONException e) {
e.printStackTrace();
}
return new Gson().fromJson(json, User.class);
}

public static List<User> fromJson(String json, final List<User> old) {
try {
List<User> result = new ArrayList<>();
JSONArray array = new JSONArray(json);
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
final long id = object.getLong("id");
Gson gson = new GsonBuilder().registerTypeAdapter(User.class, new InstanceCreator<User>() {
@Override
public User createInstance(Type type) {
return getUserById(old, id);
}
}).create();
result.add(gson.fromJson(object.toString(), User.class));
}
return result;
} catch (JSONException e) {
e.printStackTrace();
}
return new Gson().fromJson(json, new TypeToken<List<User>>() {
}.getType());
}

private static User getUserById(List<User> users, long id) {
for (User user : users) {
if (user.getId() == id) {
return user;
}
}
return new User();
}
}

MainActivity测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

String json1 = "{\"age\":20,\"id\":1,\"name\":\"张三\",\"sex\":\"男\"}";
String json2 = "{\"age\":23,\"id\":1}";
String json3 = "[{\"age\":20,\"id\":1,\"name\":\"张三\",\"sex\":\"男\"},{\"age\":23,\"id\":2,\"name\":\"李四\",\"sex\":\"男\"}]";
String json4 = "[{\"age\":23,\"id\":1},{\"age\":33,\"id\":2}]";

User user1 = new Gson().fromJson(json1, User.class);
Log.e("user1", new Gson().toJson(user1));

User user2 = GsonCreatorHelper.fromJson(json2, user1);
Log.e("user2", new Gson().toJson(user2));

List<User> user3 = new Gson().fromJson(json3, new TypeToken<ArrayList<User>>() {}.getType());
Log.e("user3", new Gson().toJson(user3));

List<User> user4 = GsonCreatorHelper.fromJson(json4, user3);
Log.e("user4", new Gson().toJson(user4));
}
1
2
3
4
10-08 18:18:41.419 28438-28438/com.panda.gsoninstancecreatortest E/user1: {"age":20,"id":1,"name":"张三","sex":"男"}
10-08 18:18:41.425 28438-28438/com.panda.gsoninstancecreatortest E/user2: {"age":23,"id":1,"name":"张三","sex":"男"}
10-08 18:18:41.436 28438-28438/com.panda.gsoninstancecreatortest E/user3: [{"age":20,"id":1,"name":"张三","sex":"男"},{"age":23,"id":2,"name":"李四","sex":"男"}]
10-08 18:18:41.441 28438-28438/com.panda.gsoninstancecreatortest E/user4: [{"age":23,"id":1,"name":"张三","sex":"男"},{"age":33,"id":2,"name":"李四","sex":"男"}]

从结果中我们可以看到user2和user4都是在user1和user3的基础上继续解析了新的json数据。大功告成!

Flutter接入现有Android工程踩坑之旅

把Flutter作为一个模块接入到现有的Android工程,Flutter有官方推荐方案 Add Flutter to existing apps,通过这样的工程配置,可以在debug支持HotReload,也可以输出Release包供发布。不过在使用过程中有一些需要调整的地方,特此记录希望对大家能有借鉴意义。

工程目录调整

flutter create -t module

命令会创建一个支持Flutter的Android Library,其中Android Library的目录位于Flutter工程的隐藏目录 .android/flutter 中, 一般情况下,我们会把Flutter代码和Android代码放在两个git仓库,通过submodule的方式进行依赖,可以把这个Library的代码copy到你的工程目录下,同时修改flutter的资源目录到你自己的相对路径下:

flutter {
source ‘ your own flutter project directory
}

另外需要Copy include_flutter.groovy 这个文件到你的工程目录下,修改相应的目录添加对于Library的依赖。

armeabi支持

Flutter官方只提供了四种CPU架构的SO库:armeabi-v7a、arm64-v8a、x86和x86-64。但是目前我们对接的两个项目组分别是只支持armeabi和只支持armeabi-v7a,所以需要对官方的jar包进行改造。官方SDK提供的jar包路径在 $flutterRoot/bin/cache/artifacts/engine中,复制这几个目录下的armeabi-v7a中的so到armeabi路径下:

  • android-arm
  • android-arm-dynamic-profile
  • android-arm-dynamic-release
  • android-arm-profile
  • android-arm-release

可以通过如下脚本实现:

1
2
3
4
unzip flutter.jar lib/armeabi-v7a/libflutter.so
mkdir lib/armeabi
cp lib/armeabi-v7a/libflutter.so lib/armeabi/libflutter.so
zip flutter.jar lib/armeabi-v7a/libflutter.so lib/armeabi/libflutter.so

Library中的build.gradle中有一段是通过本地的一个gradle文件添加flutter.jar的依赖:

1
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

我们把flutter.gradle文件以及我们刚才处理的flutter.jar文件Copy到自己的工程路径下,我自己的工程路径配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
project
│ app
└───flutter
│ │ build.gradle
│ │ flutter.gradle
│ │ include_flutter.groovy
│ └───flutter-jars
│ └───android-arm
| | flutter.jar
│ └───android-arm-dynamic-profile
| | flutter.jar
│ └───android-arm-dynamic-release
| | flutter.jar
| └───android-arm-profile
| | flutter.jar
| └───aandroid-arm-release
| | flutter.jar
│ settings.gradle

将flutter.gradle 中jar文件的路径改为本地工程:

1
2
3
4
5
debugFlutterJar = new File('flutter-jars/debug/flutter.jar')
profileFlutterJar = new File('flutter-jars/profile/flutter.jar')
releaseFlutterJar = new File('flutter-jars/release/flutter.jar')
dynamicProfileFlutterJar = new File('flutter-jars/dynamicProfile/flutter.jar')
dynamicReleaseFlutterJar = new File('flutter-jars/dynamicRelease/flutter.jar')

这样打出的AAR就能同时支持两种架构。

打包AAR问题

按照上面的配置,可以在工程中打出支持Debug HotReload和Release的包,不过在输出AAR给别的业务模块使用时会报一个崩溃:

must be able to initialize the ICU context.

这是Android Gradle Plugin 3.+ 版本的一个bug,它会丢弃flutter.jar 中的 /assets/flutter_shared/icudtl.dat文件到AAR中,导致运行时找不到这个文件崩溃,在2.+版本中发现没有这个问题,所以需要使用Android Gradle Plugin 2.+版本,我这边测试2.2.3版本是ok的。但是Android Gradle 2的版本有一个由来已久的问题就是Library不能获取project一致 的BuildType,Library默认只发布Release的AAR。这是因为Android中默认指定了发布type:

1
2
private String defaultPublishConfig = "release";
private boolean publishNonDefault = false;

默认Release,而flutter.gradle中通过buildtype来确定flutter的buildmode,在Android Gradle Plugin 3.+版本中,这个buildtype的问题已经得到解决,这也可能是flutter选用3.+版本的一个原因。

如果避免2.+的buildtype问题呢,网上是有一些获取project的buildtype配置给Library的方案,比如如何让library的buildType类型跟app的buildType类型一致(自由定义library的buildType) ??。 我的实现方案是摈弃通过buildtype确定flutter的buildmode的方案,通过直接读取本地local.properties中的参数来决定,这样需要自己在本地手动的进行mode的切换,尤其是要注意上线的时候修改为Release模式。不过debug模式下页面有明显的debug标识,所以一般也不会出错。将flutter.gradle 中原有的buildmodefor方法:

1
2
3
4
5
6
7
8
9
10
11
12
private static String buildModeFor(buildType) {
if (buildType.name == "profile") {
return "profile"
} else if (buildType.name == "dynamicProfile") {
return "dynamicProfile"
} else if (buildType.name == "dynamicRelease") {
return "dynamicRelease"
} else if (buildType.debuggable) {
return "debug"
}
return "release"
}

修改为:

1
2
3
private String buildModeFor(Project project) {
return resolveProperty(project, 'flutter.buildMode', 'release')
}

这样在local.properties 中就可以进行debug和Release的切换:

flutter.buildMode=release

flutter.buildMode=debug

切换mode的崩溃问题

在第一次配置好工程或者切换mode的过程中,可能会遇到以下的崩溃问题:

Check failed: vm. Must be able to initialize the VM.

主要是由于不同模式下的产物没有清理使用了缓存,解决办法是删除掉所有build文件的内容再全量编译一次就可以了。

总结

这是我在做flutter工程配置中遇到的一些坑,文风偏流水账,请大家见谅,只希望能对大家有一些借鉴意义。另外,我们已经在项目的两个模块中使用了flutter,开发效率确实能有很大提高,毕竟两端只需要一个人开发就ok,而且UI小姐姐要求的页面效果都能不折不扣的完成,上线之后目前还没发现什么问题。下一步需要做的是建立一个有效的监控体系,毕竟靠用户反馈还是不可靠也是滞后的。相信Flutter的未来一定是光明的!

HTTPS原理及OKHTTP对HTTPS的支持

HTTPS原理

我们先看一下定义,来自wikipedia的一个介绍:

HTTPS (also called HTTP over Transport Layer Security (TLS), HTTP over SSL, and HTTP Secure) is a communications protocol for secure communication over a computer network which is widely used on the Internet. HTTPS consists of communication over Hypertext Transfer Protocol (HTTP) within a connection encrypted by Transport Layer Security, or its predecessor, Secure Sockets Layer. The main motivation for HTTPS is authentication of the visited website and protection of the privacy and integrity of the exchanged data.

从这个定义中我们可以看出,HTTPS是包含了HTTP协议及SSL /TLS协议这两部分内容,简单的理解就是基于SSL/TLS进行HTTP的加密传输。HTTP是一个应用层的协议,定义了很多请求和响应方通信的遵循的规则,这部分内容可以从HTTP权威指南这部巨作中得到很详细的介绍,这里就不赘述了。其实主要还是想探讨一下SSL/TLS协议的一些具体细节,毕竟这是HTTPS区别于HTTP最大的地方,首先我们来看一下一个SSL/TLS完整的握手过程。

SSL/TLS握手过程

很复杂的交互过程,但是理解下来就是用非对称加密的手段传递密钥,然后用密钥进行对称加密传递数据。在这个握手过程中最重要的就是证书校验,其他就是正常的数据交互过程。如何校验一个证书合法有很大的文章,处理不好就会让你的网络失去了安全性。一个证书的校验,主要包括以下几个方面:

  • 第一,校验证书是否是由客户端中“受信任的根证书颁发机构”颁发;
  • 第二,校验证书是否在上级证书的吊销列表;
  • 第三,校验证书是否过期;
  • 第四,校验证书域名是否一致。

一天我们的QA妹子气愤愤的找到我说,为啥别人的APP可以用Charles抓到HTTPS的包,为啥我们的不能,我心中窃喜的告诉她只能说明我们技高一筹了。具体如何做到的后面我会分享一下我们的做法,先讨论一下Charles如何实现https的抓包的,这里面涉及到一个中间人攻击的问题。

一个针对SSL的中间人攻击过程如下:

image.png

中间人其实是做了一个偷梁换柱的动作,核心是如何欺骗客户端,从而让客户端能够放心的与中间人进行数据交互而没有任何察觉。我们来看Charles如何做到HTTPS抓包的,网上有很多Charles如何抓HTTPS包的教程,几步就搞定了,其中最核心的就是:

将私有CA签发的数字证书安装到手机中并且作为受信任证书保存

自签发一个证书实现上述二、三、四条校验规则很简单,要把这个证书安装到手机端信任列表必须得到用户的许可,这里不好绕过,但是鉴于大部分用户的网络安全意识比较差,有时也会稀里糊涂的信任了,那我们作为APP的开发人员,能否避免这种情况的发生呢?

其实也很简单,我们把服务端的证书内置在我们的APP里,我们在做服务端证书校验的时候只比对是否和这个证书完全相同,不同就直接抛错,那中间人便没有办法绕过证书进行攻击。但是这里面也有一个问题就是服务端的证书可能会过期或者升级,而且服务端往往为了提高网络的安全性,证书的有效时间不会设置太长,这样APP就会因为这个证书的事情频繁发版,也很痛苦。(前段时间我司IOS的APP就是因为授权企业用户的证书没有及时更新,导致大家无法正常打开APP,血的教训导致我们不想重走这条路)可能你又想到了,我们可以把证书配置在后端,有更新的时候直接去下载不就完了,那我们的证书下载没有没拦截的风险吗,一旦拦截,我们所有的证书校验都会失效,比直接信任手机内置的证书更可怕。我们既不想只信任我们服务器的证书,又不想信任手机上所有的 CA 证书。有个不错的的信任方式是把签发我们服务器的证书的根证书导出打包到APP中,这样虽然不能做到百分之百的证书无漏洞,但是相比于信任手机中几百个证书,我们只信任一个风险会小很多,这也就是我们的QA妹子用Charles抓不了我们的包的原因。~~~

OKHTTP

作为一个Android开发者,我们来看一下广泛使用的网络库OKHTTP对于HTTPS的支持。下面这段话摘自OKHTTP对于HTTPS的介绍中(地址请戳):

OkHttp attempts to balance two competing concerns:

  • Connectivity to as many hosts as possible. That includes advanced hosts that run the latest versions of boringssl and less out of date hosts running older versions of OpenSSL.
  • Security of the connection. This includes verification of the remote webserver with certificates and the privacy of data exchanged with strong ciphers.

几个与HTTPS相关的API:

SSLSocketFactory:

安全套接层工厂,用于创建SSLSocket。默认的SSLSocket是信任手机内置信任的证书列表,我们可以通过OKHttpClient.Builder的sslSocketFactory方法定义我们自己的信任策略,比如实现上面提到的我们只信任服务端证书的根证书,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 载入证书
*/
public static SSLSocketFactory getSSLSocketFactory(InputStream... certificates) {
try {
//用我们的证书创建一个keystore
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null);
int index = 0;
for (InputStream certificate : certificates) {
String certificateAlias = "server"+Integer.toString(index++);
keyStore.setCertificateEntry(certificateAlias, certificateFactory.generateCertificate(certificate));
try {
if (certificate != null) {
certificate.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
//创建一个trustmanager,只信任我们创建的keystore
SSLContext sslContext = SSLContext.getInstance("TLS");
TrustManagerFactory trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
sslContext.init(
null,
trustManagerFactory.getTrustManagers(),
new SecureRandom()
);
return sslContext.getSocketFactory();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

X509TrustManager:

1
2
3
4
5
6
7
public interface X509TrustManager extends TrustManager {
void checkClientTrusted(X509Certificate[] var1, String var2) throws CertificateException;

void checkServerTrusted(X509Certificate[] var1, String var2) throws CertificateException;

X509Certificate[] getAcceptedIssuers();
}

checkServerTrusted方式实现了对于服务端校验,这里一般使用系统默认的实现,有些教程讲到这样配置ssl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[]{
new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {

}

public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
}

public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
}, null);
return sslContext.getSocketFactory();
} catch (GeneralSecurityException e) {
throw new AssertionError();
}
}

千万不能这么做,这样将你是没有做任何校验的,这里推荐使用系统默认的,他会在校验过程中发现有异常直接抛出。

HostnameVerifier:

1
2
3
public interface HostnameVerifier {
boolean verify(String var1, SSLSession var2);
}

这个接口主要实现对于域名的校验,OKHTTP实现了一个OkHostnameVerifier,对于证书中的IP及Host做了各种正则匹配,默认情况下使用的是这个策略。有时你遇到了一些奇怪的校验问题,大部分教程会教你这样:

1
2
3
4
5
6
OKHttpClient.Builder.hostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
})

其实这样你是完全放弃了hostname的校验,这也是相当不安全的。