暂时未有相关云产品技术能力~
目录介绍 01.学习JNI开发流程 1.1 JNI开发概念 1.2 JNI和NDK的关系 1.3 JNI实践步骤 1.4 NDK使用场景 1.5 学习路线说明 02.NDK架构分层 2.1 NDK分层构建层 2.2 NDK分层Java层 2.3 Native层 03.JNI基础语法 3.1 JNI三种引用 3.2 JNI异常处理 3.3 C和C++互相调用 3.4 JNI核心原理 3.5 注册Native函数 3.6 JNI签名是什么 04.一些必备操作 4.1 so库生成打包 4.2 so库查询操作 4.3 so库如何反编译 05.实践几个案例 5.1 Java静态调用C/C++ 5.2 C/C++调用Java 5.3 Java调三方so中API 5.4 Java动态调C++ 06.一些技术原理 6.1 JNIEnv创建和释放 6.2 动态注册的原理 6.3 注册JNI流程图 07.JNI遇到的问题 7.1 混淆的bug 7.2 注意字符串编译 01.学习JNI开发流程 1.1 JNI开发概念 .SO库是什么东西 NDK为了方便使用,提供了一些脚本,使得更容易的编译C/C++代码。在Android程序编译中会将C/C++ 编译成动态库 so 文件,类似java库.jar文件一样,它的生成需要使用NDK工具来打包。 so是shared object的缩写,见名思义就是共享的对象,机器可以直接运行的二进制代码。实质so文件就是一堆C、C++的头文件和实现文件打包成一个库。 JNI是什么东西 JNI的全称是Java Native Interface,即本地Java接口。因为 Java 具备跨平台的特点,所以Java 与 本地代码交互的能力非常弱。 采用JNI特性可以增强 Java 与本地代码交互的能力,使Java和其他类型的语言如C++/C能够互相调用。 1.2 JNI和NDK的关系 JNI和NDK学习内容太难 其实难的不是JNI和NDK,而是C/C++语言,JNI和NDK只是个工具,很容易学习的。 JNI和NDK有何联系 学习JNI之前,首先得先知道JNI、NDK、Java和C/C++之间的关系。 在Android开发中,有时为了性能和安全性(反编译),需要使用C/C++语言,但是Android APP层用的是Java语言,怎么才能让这两种语言进行交流呢,因为他们的编码方式是不一样的,这是就需要JNI了。 JNI可以被看作是代理模式,JNI是java接口,用于Java与C/C++之间的交互,作为两者的桥梁,也就是Java让JNI代其与C/C++沟通。 NDK是Android工具开发包,帮助快速开发C/C++动态库,相当于JDK开发java程序一样,同时能帮打包生成.so库 1.3 JNI实践步骤 操作实践步骤 第一步,编写native方法。 第二步,根据此native方法编写C文件。 第三步,使用NDK打包成.so库。 第四步,使用.so库然后调用api。 如何使用NDK打包.so库 1,编写Android.mk文件,此文件用来告知NDK打包.so库的规则 2,使用ndk-build打包.so库 相关学习文档 NDK学习:https://developer.android.google.cn/ndk/guides?hl=zh-cn 1.4 NDK使用场景 NDK的使用场景一般在: 1.为了提升这些模块的性能,对图形,视频,音频等计算密集型应用,将复杂模块计算封装在.so或者.a文件中处理。 2.使用的是C/C++进行编写的第三方库移植。如ffmppeg,OpenGl等。 3.某些情况下为了提高数据安全性,也会封装so来实现。毕竟使用纯Java开发的app是有很多逆向工具可以破解的。 1.5 学习路线说明 JNI学习路线介绍 1.首先要有点C/C++的基础,这个我是在 菜鸟教程 上学习的 2.理解NDK和JNI的一些概念,以及NDK的一个大概的架构分层,JNI的开发步骤是怎样的 3.掌握案例练习,前期先写案例,比如java调用c/c++,或者c/c++调用java。把这个案例写熟,跑通即可 4.案例练习之后,然后在思考NDK是怎么编译的,如何打包so文件,loadLibrary的流程,CMake工作流程等一些基础的原理 5.在实践过程中,先记录遇到的问题。这时候可能不一定懂,先放着,先实现案例或者简单的业务。然后边实践边琢磨问题和背后的原理 注意事项介绍 避免一开始就研究原理,或者把C/C++整体学习一遍,那样会比较辛苦。焦点先放在JNI通信流程上,写案例学习 把学习内容,分为几个不同类型:了解(能够扯淡),理解(大概知道什么意思),掌握(能够运用和实践),精通(能举一反三和分享讲清楚) 02.NDK架构分层 使用NDK开发最终目标是为了将C/C++代码编译生成.so动态库或者静态库文件,并将库文件提供给Java代码调用。 所以按架构来分可以分为以下三层: 1.构建层 2.Java层 3.native层 2.1 NDK分层构建层 要得到目标的so文件,需要有个构建环境以及过程,将这个过程和环境称为构建层。 构建层需要将C/C++代码编译为动态库so,那么这个编译的过程就需要一个构建工具,构建工具按照开发者指定的规则方式来构建库文件,类似apk的Gradle构建过程。 在讲解NDK构建工具之前,我们先来了解一些关于CPU架构的知识点:Android abi ABI即Application Binary Interface,定义了二进制接口交互规则,以适应不同的CPU,一个ABI对应一种类型的CPU。 Android目前支持以下7种ABI: 1.armeabi:第5代和6代的ARM处理器,早期手机用的比较多。 2.armeabi-v7a:第7代及以上的 ARM 处理器。 3.arm64-v8a:第8代,64位ARM处理器 4.x86:一般用在平板,模拟器。 5.x86_64:64位平板。 常规的NDK构建工具有两种: 1.ndk-build: 2.Cmake ndk-build其实就是一个脚本。早期的NDK开发一直都是使用这种模式 运行ndk-build相当于运行一下命令:$GNUMAKE -f /build/core/build-local.mk $GNUMAKE 指向 GNU Make 3.81 或更高版本, 则指向 NDK 安装目录 使用ndk-build需要配合两个mk文件:Android.mk和Application.mk。 Cmake是一个编译系统的生成器 简单理解就是,他是用来生成makefile文件的,Android.mk其实就是一个makefile类文件,cmake使用一个CmakeLists.txt的配置文件来生成对应的makefile文件。 Cmake构建so的过程其实包括两步:步骤1:使用Cmake生成编译的makefiles文件;步骤2:使用Make工具对步骤1中的makefiles文件进行编译为库或者可执行文件。 Cmake优势在哪里呢?在生成makefile过程中会自动分析源代码,创建一个组件之间依赖的关系树,这样就可以大大缩减在make编译阶段的时间。 Cmake构建项目配置 使用Cmake进行构建需要在build.gradle配置文件中声明externalNativeBuild 2.2 NDK分层Java层 如何选择正确的so库呢 通常情况下,我们在编译so的时候就需要确定自己设备类型,根据设备类型选择对应abiFilters。 注意:使用as编译后的so会自动打包到apk中,如果需要提供给第三方使用,可以到build/intermediates/cmake/debug or release 目录中copy出来。 Java层如何调用so文件中的函数 对于Android上层代码来说,在将包正确导入到项目中后,只需要一行代码就可以完成动态库的加载过程。有两种方式:System.load("/data/local/tmp/native_lib.so"); System.loadLibrary("native_lib"); 1.加载路径不同:load是加载so的完整路径,而loadLibrary是加载so的名称,然后加上前缀lib和后缀.so去默认目录下查找。 2.自动加载库的依赖库的不同:load不会自动加载依赖库;而loadLibrary会自动加载依赖库。 无论哪种方式,最终都会调用到LoadNativeLibrary()方法,该方法主要操作: 1.通过dlopen打开动态库文件 2.通过dlsym找到JNI_OnLoad符号所对应的方法地址 3.通过JNI_OnLoad去注册对应的jni方法 2.3 Native层 如何理解JNI的设计思想 JNI(全名Java Native Interface)Java native接口,其可以让一个运行在Java虚拟机中的Java代码被调用或者调用native层的用C/C++编写的基于本机硬件和操作系统的程序。简单理解为就是一个连接Java层和Native层的桥梁。 开发者可以在native层通过JNI调用到Java层的代码,也可以在Java层声明native方法的调用入口。 JNI注册方式 当Java代码中执行Native的代码的时候,首先是通过一定的方法来找到这些native方法。JNI有静态注册和动态注册两种注册方式。 静态注册先由Java得到本地方法的声明,然后再通过JNI实现该声明方法。动态注册先通过JNI重载JNI_OnLoad()实现本地方法,然后直接在Java中调用本地方法。 03.JNI基础语法 3.1 JNI三种引用 在JNI规范中定义了三种引用: 局部引用(Local Reference)、全局引用(Global Reference)、弱全局引用(Weak Global Reference)。 Local引用 JNI中使用 jobject, jclass, and jstring等来标志一个Java对象,然而在JNI方法在使用的过程中会创建很多引用类型,如果使用过程中不注意就会导致内存泄露。 直接使用:NewLocalRef来创建。Local引用其实就是Java中的局部引用,在声明这个局部变量的方法结束或者退出其作用域后就会被GC回收。 Global引用全局引用 全局引用可以跨方法、跨线程使用,直到被开发者显式释放。一个全局引用在被释放前保证引用对象不被GC回收。 和局部应用不同的是,能创建全局引用的函数只有NewGlobalRef,而释放它需要使用ReleaseGlobalRef函数。 Weak引用 弱引用可以使用全局声明的方式。弱引用在内存不足或者紧张的时候会自动回收掉,可能会出现短暂的内存泄露,但是不会出现内存溢出的情况。 3.2 JNI异常处理 native层异常 处理方式1:native层自行处理 处理方式2:native层抛出给Java层处理 3.4 JNI核心原理 java运行在jvm,jvm本身就是使用C/C++编写的,因此jni只需要在java代码、jvm、C/C++代码之间做切换即可 JNIEnv是什么? JINEnv是当前Java线程的执行环境,一个JVM对应一个JavaVM结构体,一个JVM中可能创建多个Java线程,每个线程对应一个JNIEnv结构,它们保存在线程本地存储TLS中。 因此不同的线程JNIEnv不同,而不能相互共享使用。 JavaEnv结构也是一个函数表,在本地代码通过JNIEnv函数表来操作Java数据或者调用Java方法。 3.5 注册Native函数 JNI静态注册: 步骤1.在Java中声明native方法,比如:public native String stringFromJNI() 步骤2.在native层新建一个C/C++文件,并创建对应的方法(建议使用AS快捷键自动生成函数名),比如:testjnilib.cpp: Line 8 JNI动态注册 通过RegisterNatives方法把C/C++中的方法映射到Java中的native方法,而无需遵循特定的方法命名格式,这样书写起来会省事很多。 动态注册其实就是使用到了前面分析的so加载原理:在最后一步的JNI_OnLoad中注册对应的jni方法。这样在类加载的过程中就可以自动注册native函数。比如: 与JNI_OnLoad()函数相对应的有JNI_OnUnload()函数,当虚拟机释放该C库的时候,则会调用JNI_OnUnload()函数来进行善后清除工作。 那么如何选择使用静态注册or动态注册 动态注册和静态注册最终都可以将native方法注册到虚拟机中,推荐使用动态注册,更不容易写错,静态注册每次增加一个新的方法都需要查看原函数类的包名。 3.6 JNI签名是什么 为什么JNI中突然多出了一个概念叫”签名”: 因为Java是支持函数重载的,也就是说,可以定义相同方法名,但是不同参数的方法,然后Java根据其不同的参数,找到其对应的实现的方法。 这样是很好,所以说JNI肯定要支持的,如果仅仅是根据函数名,没有办法找到重载的函数的,所以为了解决这个问题,JNI就衍生了一个概念——”签名”,即将参数类型和返回值类型的组合。 如果拥有一个该函数的签名信息和这个函数的函数名,就可以顺序的找到对应的Java层中的函数。 如何查看签名呢:可以使用javap命令。 javap -s -p MainActivity.class 04.一些必备操作 4.1 so库生成打包 什么是so文件库 so库,即将C或者C++实现的功能进行打包,将其打包为共享库,让其他程序进行调用,这可以提高代码的复用性。 关于.so文件的生成有两种方式 可以提供给大家参考,一种是CMake自动生成法,另一种是传统打包法。 so文件在程序运行时就会加载 所以想使用Java调用.so文件,必有某个Java类运行时load了native库,并通过JNI调用了它的方法。 cmake生成.so方案 第一步:创建native C++ Project项目,创建native函数并实现,先测试本地JNI函数调通 第二步:获取.so文件。将生成的.apk文件改为.zip文件,然后进行解压缩,就能看到.so文件。如果想支持多种库架构,则可在module的build.gradle中配置ndk支持。 第三步:so文件测试。新建一个普通的Android程序,将so库放入程序,然后创建类(注意要相同的包名、文件名及方法名)去加载so库。 总结一下:Android Studio自动创建的native C++项目默认支持CMake方式,它支持JNI函数调用的入口在build.gradle中。 传统打包生成.so方案【不推荐这种方式】 第一步:在Java类中声明一个本地方法。 第二步:执行指令javah获得C声明的.h文件。 第三步:获得.c文件并实现本地方法。创建Android.mk和Application.mk,并配置其参数,两个文件如不编写或编写正常会出现报错。 第四步:打包.so库。cd到\app目录下,执行命令 ndk-build即可。生成so库后,最后测试ok即可。 4.2 so库查询操作 so库如何查找所对应的位置 第一步:在 app 模块的 build.gradle 中,追加以下代码: 第二步:执行命令行:./gradlew assembleDebug 【注意如果遇到gradlew找不到,则输入:chmod +x gradlew】 so文件查询结果后。就可以查询到so文件属于那个lib库的!如下所示:libtestjnilib.so文件属于TestJniLib库的 find so file: /Users/yc/github/YCJniHelper/TestJniLib/build/intermediates/library_jni/debug/jni/armeabi-v7a/libtestjnilib.so find so file: /Users/yc/github/YCJniHelper/SafetyJniLib/build/intermediates/library_jni/debug/jni/armeabi-v7a/libsafetyjnilib.so find so file: /Users/yc/github/YCJniHelper/SignalHooker/build/intermediates/library_jni/debug/jni/armeabi-v7a/libsignal-hooker.so 05.实践几个案例 5.1 Java静态调用C/C++ Java调用C/C++函数调用流程 Java层调用某个函数时,会从对应的JNI层中寻找该函数。根据java函数的包名、方法名、参数列表等多方面来确定函数是否存在。 如果没有就会报错,如果存在就会就会建立一个关联关系,以后再调用时会直接使用这个函数,这部分的操作由虚拟机完成。 Java层调用C/C++方法操作步骤 第一步:创建java类NativeLib,然后定义native方法stringFromJNI()public native String stringFromJNI(); 第二步:根据此native方法编写C文件,可以通过命令后或者studio提示生成C++对应的方法函数 //java中stringFromJNI //extern “C” 指定以"C"的方式来实现native函数 extern "C" //JNIEXPORT 宏定义,用于指定该函数是JNI函数。表示此函数可以被外部调用,在Android开发中不可省略 JNIEXPORT jstring //JNICALL 宏定义,用于指定该函数是JNI函数。,无实际意义,但是不可省略 JNICALL //以注意到jni的取名规则,一般都是包名 + 类名,jni方法只是在前面加上了Java_,并把包名和类名之间的.换成了_ Java_com_yc_testjnilib_NativeLib_stringFromJNI(JNIEnv *env, jobject /* this */) { //JNIEnv 代表了JNI的环境,只要在本地代码中拿到了JNIEnv和jobject //JNI层实现的方法都是通过JNIEnv 指针调用JNI层的方法访问Java虚拟机,进而操作Java对象,这样就能调用Java代码。 //jobject thiz //在AS中自动为我们生成的JNI方法声明都会带一个这样的参数,这个instance就代表Java中native方法声明所在的 std::string hello = "Hello from C++"; //思考一下,为什么直接返回字符串会出现错误提示? //return "hello"; return env->NewStringUTF(hello.c_str()); } 举一个例子 例如在 NativeLib 类的native stringFromJNI()方法,程序会自动在JNI层查找 Java_com_yc_testjnilib_NativeLib_stringFromJNI 函数接口,如未找到则报错。如找到,则会调用native库中的对应函数。 5.2 C/C++调用Java Native层调用Java层的类的字段和方法的操作步骤 第一步:创建一个Native C++的Android项目,创建 Native Lib 项目 第二步:在cpp文件夹下创建:calljnilib.cpp文件,calljnilib.h文件(用来声明calljnilib.cpp中的方法)。 第三步:开始编写配置文件CmkaeLists.txt文件。使用add_library创建一个新的so库 第四步:编写 calljnilib.cpp文件。因为要实现native层调用Java层字段和方法,所以这里定义了两个方法:callJavaField和callJavaMethod 第五步:编写Java层的调用代码此处要注意的是调用的类的类名以及包名都要和c++文件中声明的一致,否则会报错。具体看:CallNativeLib 第六步:调用代码进行测试。然后查看测试结果 5.3 Java调三方so中API 直接拿前面案例的 calljnilib.so 来测试,但是为了实现三方调用还需要对文件进行改造 第一步:要实现三方so库调用,在 calljnilib.h中声明两个和 calljnilib.cpp中对应的方法:callJavaField和callJavaMethod,一般情况下这个头文件是第三方库一起提供的给外部调用的。 第二步:对CMakeLists配置文件改造。主要是做一些库的配置操作。 第三步:编写 third_call.cpp文件,在这内部调用第三方库。这里需要将第三方头文件导入进来,如果CmakeLists文件中没有声明头文件,就使用#include "include/calljnilib.h" 这种方式导入 第四步:最后测试下:callThirdSoMethod("com/yc/testjnilib/HelloCallBack","updateName"); 5.4 Java动态调C++ 先说一下静态调C++的问题: 在实现stringFromJNI()时,可以看到c++里面的方法名很长 Java_com_yc_testjnilib_NativeLib_stringFromJNI。 这是jni静态注册的方式,按照jni规范的命名规则进行查找,格式为Java类路径方法名。Studio默认这种方式名字太长了,能否设置短一点。 程序运行效率低,因为初次调用native函数时需要根据根据函数名在JNI层中搜索对应的本地函数,然后建立对应关系,这个过程比较耗时。 动态注册方法解决上面问题 当程序在Java层运行System.loadLibrary("testjnilib");这行代码后,程序会去载入testjnilib.so文件。 于此同时,产生一个Load事件,这个事件触发后,程序默认会在载入的.so文件的函数列表中查找JNI_OnLoad函数并执行。与Load事件相对,在载入的.so文件被卸载时,Unload事件被触发。 此时,程序默认会去载入的.so文件的函数列表中查找JNI_OnLoad函数并执行,然后卸载.so文件。 因此开发者经常会在JNI_OnLoad中做一些初始化操作,动态注册就是在这里进行的,使用env->RegisterNatives(clazz, gMethods, numMethods)。 动态注册操作步骤: 第一步:因为System.loadLibrary()执行时会调用此方法,实现JNI_OnLoad方法。 第二步:调用FindClass找到需要动态注册的java类【定义要关联的对应Java类】,注意这个是native方法那个类的路径字符串 第三步:定义一个静态数据(JNINativeMethod类型),里面存放需要动态注册的native方法,以及参数名称 第四步:通过调用jni中的RegisterNatives函数将注册函数的Java类,以及注册函数的数组,以及个数注册在一起,这样就实现了绑定。 动态注册优势分析 相比静态注册,动态注册的灵活性更高,如果修改了native函数所在类的包名或类名,仅调整native函数的签名信息即可。 还有一个优势:动态注册,java代码不需要更改,只需要更改native代码。 效率更高:通过在.so文件载入初始化时,即JNI_OnLoad函数中,先行将native函数注册到VM的native函数链表中去,后续每次java调用native函数时都会在VM中的native函数链表中找到对应的函数,从而加快速度。 06.一些技术原理 6.1 JNIEnv创建和释放 JNIEnv的创建方式 C 中——JNIInvokeInterface:JNIInvokeInterface是C语言环境中的JavaVM结构体,调用 (AttachCurrentThread)(JavaVM, JNIEnv*, void) 方法,能够获得JNIEnv结构体; C++中 ——_JavaVM:_JavaVM是C++中JavaVM结构体,调用jint AttachCurrentThread(JNIEnv* p_env, void thr_args) 方法,能够获取JNIEnv结构体; JNIEnv的释放: C 中释放:调用JavaVM结构体JNIInvokeInterface中的(DetachCurrentThread)(JavaVM)方法,能够释放本线程的JNIEnv C++ 中释放:调用JavaVM结构体_JavaVM中的jint DetachCurrentThread(){ return functions->DetachCurrentThread(this); } 方法,就可以释放 本线程的JNIEnv JNIEnv和线程的关系 JNIEnv只在当前线程有效:JNIEnv仅仅在当前线程有效,JNIEnv不能在线程之间进行传递,在同一个线程中,多次调用JNI层方便,传入的JNIEnv是同样的 本地方法匹配多个JNIEnv:在Java层定义的本地方法,能够在不同的线程调用,因此能够接受不同的JNIEnv 6.2 动态注册的原理 在Android源码开发环境下,大多采用动态注册native方法。 利用结构体JNINativeMethod保存Java Native函数和JNI函数的对应关系; 在一个JNINativeMethod数组中保存所有native函数和JNI函数的对应关系; 在Java中通过System.loadLibrary加载完JNI动态库之后,调用JNI_OnLoad函数,开始动态注册; JNI_OnLoad中会调用AndroidRuntime::registerNativeMethods函数进行函数注册; AndroidRuntime::registerNativeMethods中最终调用jni RegisterNativeMethods完成注册。 动态注册原理分析 RegisterNatives 方式的本质是直接通过结构体指定映射关系,而不是等到调用 native 方法时搜索 JNI 函数指针,因此动态注册的 native 方法调用效率更高。 此外,还能减少生成 so 库文件中导出符号的数量,则能够优化 so 库文件的体积。 6.3 注册JNI流程图 提到了注册 JNI 函数(建立 Java native 方法和 JNI 函数的映射关系)有两种方式:静态注册和动态注册。 分析下静态注册匹配 JNI 函数的执行过程 第一步:以 loadLibrary() 加载 so 库的执行流程为线索进行分析的,最终定位到 FindNativeMethod() 这个方法。 第二步:查看java_vm_ext.cc中FindNativeMethod方法,然后看到jni_short_name和jni_long_name,获取native方法对应的短名称和长名称。 第三步:在java_vm_ext.cc,通过FindNativeMethodInternal查找已经加载的so库中搜索,先搜索短名称,然后再搜索长名称 第四步:建立内部数据结构,建立 Java native 方法与 JNI 函数的函数指针的映射关系,调用 native 方法,则直接调用已记录的函数指针。 07.JNI遇到的问题 7.1 混淆的bug 在Android工程中要排除对native方法以及所在类的混淆(java工程不需要),否则要注册的java类和java函数会找不到。proguard-rules.pro中添加。 # 设置所有 native 方法不被混淆 -keepclasseswithmembernames class * { native <methods>; } # 不混淆类 -keep class com.yc.testjnilib.** { *; } 7.2 注意字符串编译 比如:对于JNI方法来说,使用如下方法返回或者调用直接崩溃了,有点搞不懂原理? env->CallMethod(objCallBack,_methodName,"123"); 这段代码编译没问题,但是在运行的时候就报错了: JNI DETECTED ERROR IN APPLICATION: use of deleted global reference 最终定位到是最后一个参数需要使用jstring而不能直接使用字符串表示。如下所示: //思考一下,为什么直接返回字符串会出现错误提示?为何这样设计…… //return "hello"; return env->NewStringUTF(hello.c_str()); 代码案例:https://github.com/yangchong211/YCJniHelper 其他案例:https://github.com/yangchong211/YCAppTool
目录介绍01.整体概述说明1.1 项目背景介绍1.2 遇到问题记录1.3 基础概念介绍1.4 设计目标1.5 产生收益分析02.市面存储方案2.1 缓存存储有哪些2.2 缓存策略有哪些2.3 常见存储方案2.4 市面存储方案说明2.5 存储方案的不足03.存储方案原理3.1 Sp存储原理分析3.2 MMKV存储原理分析3.3 LruCache考量分析3.4 DiskLru原理分析3.5 DataStore分析3.6 HashMap存储分析3.7 Sqlite存储分析3.8 使用存储的注意点3.9 各种数据存储文件04.通用缓存方案思路4.1 如何兼容不同缓存4.2 打造通用缓存Api4.3 切换不同缓存方式4.4 缓存的过期处理4.5 缓存的阀值处理4.6 缓存的线程安全性4.7 缓存数据的迁移4.8 缓存数据加密处理4.9 缓存效率的对比05.方案基础设计5.1 整体架构图5.2 UML设计图5.3 关键流程图5.4 模块间依赖关系06.其他设计说明6.1 性能设计说明6.2 稳定性设计6.3 灰度设计6.4 降级设计6.5 异常设计说明6.6 兼容性设计6.7 自测性设计07.通用Api设计7.1 如何依赖该库7.2 初始化缓存库7.3 切换各种缓存方案7.4 数据的存和取7.5 线程安全考量7.6 查看缓存文件数据7.7 如何选择合适方案08.其他说明介绍8.1 遇到的坑分析8.2 遗留的问题8.3 未来的规划8.4 参考链接记录01.整体概述说明1.1 项目背景介绍项目中很多地方使用缓存方案有的用sp,有的用mmkv,有的用lru,有的用DataStore,有的用sqlite,如何打造通用api切换操作不同存储方案?缓存方案众多,且各自使用场景有差异,如何选择合适的缓存方式?针对不同场景选择什么缓存方式,同时思考如何替换之前老的存储方案,而不用花费很大的时间成本!针对不同的业务场景,不同的缓存方案。打造一套通用的方案屏蔽各种缓存方式的差异性,暴露给外部开发者统一的API,外部开发者简化使用,提高开发效率和使用效率……1.2 遇到问题记录记录几个常见的问题问题1:各种缓存方案,分别是如何保证数据安全的,其内部使用到了哪些锁?由于引入锁,给效率上带来了什么影响?问题2:各种缓存方案,进程不安全是否会导致数据丢失,如何处理数据丢失情况?如何处理脏数据,其原理大概是什么?问题3:各种缓存方案使用场景是什么?有什么缺陷,为了解决缺陷做了些什么?比如sp存在缺陷的替代方案是DataStore,为何这样?问题4:各种缓存方案,他们的缓存效率是怎样的?如何对比?接入该库后,如何做数据迁移,如何覆盖操作?思考一个K-V框架的设计问题1-线程安全:使用K-V存储一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;问题2-内存缓存:由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;问题3-事务:由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;问题4-事务串行化:由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;问题5-异步或同步写回:由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;有时候又要求数据读写是同步的;问题6-增量更新:由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;问题7-变更回调:由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;问题8-多进程:由于程序可能有多进程需求,那么框架如何保证多进程数据同步?问题9-可用性:由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;问题10-高效性:性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;问题11-安全性:如果程序需要存储敏感数据,如何保证数据完整性和保密性;问题12-数据迁移:如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;问题13-研发体验:是否模板代码冗长,是否容易出错。各种K—V框架使用体验如何?常见存储框架设计思考导图1.3 基础概念介绍最初缓存的概念提及缓存,可能很容易想到Http的缓存机制,LruCache,其实缓存最初是针对于网络而言的,也是狭义上的缓存,广义的缓存是指对数据的复用。缓存容量,就是缓存的大小每一种缓存,总会有一个最大的容量,到达这个限度以后,那么就须要进行缓存清理了框架。这个时候就需要删除一些旧的缓存并添加新的缓存。1.4 设计目标打造通用存储库:设计一个缓存通用方案,其次,它的结构需要很简单,因为很多地方需要用到,再次,它得线程安全。灵活切换不同的缓存方式,使用简单。内部开源该库:作为技术沉淀,当作专项来推动进展。高复用低耦合,便于拓展,可快速移植,解决各个项目使用内存缓存,sp,mmkv,sql,lru,DataStore的凌乱。抽象一套统一的API接口。1.5 产生收益分析统一缓存API兼容不同存储方案打造通用api,抹平了sp,mmkv,sql,lru,dataStore等各种方案的差异性。简化开发者使用,功能强大而使用简单!02.市面存储方案2.1 缓存存储有哪些比较常见的是内存缓存以及磁盘缓存。内存缓存:这里的内存主要指的存储器缓存;磁盘缓存:这里主要指的是外部存储器,手机的话指的就是存储卡。内存缓存:通过预先消耗应用的一点内存来存储数据,便可快速的为应用中的组件提供数据,是一种典型的以空间换时间的策略。磁盘缓存:读取磁盘文件要比直接从内存缓存中读取要慢一些,而且需要在一个UI主线程外的线程中进行,因为磁盘的读取速度是不能够保证的,磁盘文件缓存显然也是一种以空间换时间的策略。二级缓存:内存缓存和磁盘缓存结合。比如,LruCache将图片保存在内存,存取速度较快,退出APP后缓存会失效;而DiskLruCache将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache会慢上一些。2.2 缓存策略有哪些缓存的核心思想主要是什么呢一般来说,缓存核心步骤主要包含缓存的添加、获取和删除这三类操作。那么为什么还要删除缓存呢?不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。这个跟线程池满了以后的线程处理策略相似!缓存的常见的策略有那些FIFO(first in first out):先进先出策略,相似队列。LFU(less frequently used):最少使用策略,RecyclerView的缓存采用了此策略。LRU(least recently used):最近最少使用策略,Glide在进行内存缓存的时候采用了此策略。2.3 常见存储方案内存缓存:存储在内存中,如果对象销毁则内存也会跟随销毁。如果是静态对象,那么进程杀死后内存会销毁。Map,LruCache等等磁盘缓存:后台应用有可能会被杀死,那么相应的内存缓存对象也会被销毁。当你的应用重新回到前台显示时,你需要用到缓存数据时,这个时候可以用磁盘缓存。SharedPreferences,MMKV,DiskLruCache,SqlLite,DataStore,Room,Realm,GreenDao等等2.4 市面存储方案说明内存缓存Map:内存缓存,一般用HashMap存储一些数据,主要存储一些临时的对象LruCache:内存淘汰缓存,内部使用LinkedHashMap,会淘汰最长时间未使用的对象磁盘缓存SharedPreferences:轻量级磁盘存储,一般存储配置属性,线程安全。建议不要存储大数据,不支持跨进程!MMKV:腾讯开源存储库,内部采用mmap。DiskLruCache:磁盘淘汰缓存,写入数据到file文件SqlLite:移动端轻量级数据库。主要是用来对象持久化存储。DataStore:旨在替代原有的 SharedPreferences,支持SharedPreferences数据的迁移Room/Realm/GreenDao:支持大型或复杂数据集其他开源缓存库ACache:一款高效二级存储库,采用内存缓存和磁盘缓存2.5 存储方案的不足存储方案SharedPreferences的不足1.SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。2.SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API3.commit() / apply()操作可能会造成ANR问题存储方案MMKV的不足1.没有类型信息,不支持getAll。由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。2.需要引入so,增加包体积:引入MMKV需要增加的体积还是不少的。3.文件只增不减:MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。存储方案DataStore的不足1.只是提供异步API,没有提供同步API方法。在进行大量同步存储的时候,使用runBlocking同步数据可能会卡顿。2.对主线程执行同步 I/O 操作可能会导致 ANR 或界面卡顿。可以通过从 DataStore 异步预加载数据来减少这些问题。03.存储方案原理3.1 Sp存储原理分析SharedPreferences,它是一个轻量级的存储类,特别适合用于保存软件配置参数。轻量级,以键值对的方式进行存储。采用的是xml文件形式存储在本地,程序卸载后会也会一并被清除,不会残留信息。线程安全的。它有一些弊端如下所示对文件IO读取,因此在IO上的瓶颈是个大问题,因为在每次进行get和commit时都要将数据从内存写入到文件中,或从文件中读取。多线程场景下效率较低,在get操作时,会锁定SharedPreferences对象,互斥其他操作,而当put,commit时,则会锁定Editor对象,使用写入锁进行互斥,在这种情况下,效率会降低。不支持跨进程通讯,由于每次都会把整个文件加载到内存中,不建议存储大的文件内容,比如大json。有一些使用上的建议如下建议不要存储较大数据;频繁修改的数据修改后统一提交而不是修改过后马上提交;在跨进程通讯中不去使用;键值对不宜过多读写操作性能分析第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,接下来所有的读操作,只需要从这个Map中取就可以3.2 MMKV存储原理分析早期微信的需求微信聊天对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的。只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。该需求对应的技术考量考量1:把聊天页面的显示文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。但这种方式涉及到io流读写,且消息多会有性能问题。考量2:App程序都崩溃了,如何保证要存储的内容,都写入到磁盘中呢?考量3:保存聊天内容到磁盘的行为,这个做成同步还是异步呢?如果是异步,如何保证聊天消息的时序性?考量4:如何存储数据是同步行为,针对群里聊天这么多消息,如何才能避免卡顿呢?考量5:存储数据放到主线程中,用户在群聊天页面猛滑消息,如何爆发性集中式对磁盘写入数据?MMKV存储框架介绍MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。MMKV设计的原理内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。数据组织:数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。写入优化:考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力。考虑将增量 kv 对象序列化后,append 到内存末尾。空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。需要在性能和空间上做个折中。MMKV诞生的背景针对该业务,高频率,同步,大量数据写入磁盘的需求。不管用sp,还是store,还是disk,还是数据库,只要在主线程同步写入磁盘,会很卡。解决方案就是:使用内存映射mmap的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。用浅显的话说:MMKV就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。3.3 LruCache考量分析在LruCache的源码中,关于LruCache有这样的一段介绍:cache对象通过一个强引用来访问内容。每次当一个item被访问到的时候,这个item就会被移动到一个队列的队首。当一个item被添加到已经满了的队列时,这个队列的队尾的item就会被移除。LruCache核心思想LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LrhCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是LRU缓存算法。LruCache使用是计数or计量使用计数策略:1、Message 消息对象池:最多缓存 50 个对象;2、OkHttp 连接池:默认最多缓存 5 个空闲连接;3、数据库连接池使用计量策略:1、图片内存缓存;2、位图池内存缓存那么思考一下如何理解 计数 or 计量 ?针对计数策略使用Lru仅仅只统计缓存单元的个数,针对计量则要复杂一点。LruCache策略能否增加灵活性在缓存容量满时淘汰,除了这个策略之外,能否再增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。比如:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。/** * 这里是参考glide中的lru缓存策略,低内存的时候清除 * @param level level级别 */ public void trimMemory(int level) { if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { clearMemory(); } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { trimToSize(maxSize() / 2); } }关于Lru更多的原理解读,可以看:AppLruCache3.4 DiskLru原理分析DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入APP时,它根据日志文件将DiskLruCache恢复到用户上次退出时的情况,日志文件journal保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。DiskLruCache缓存基础原理流程图关于DiskLruCache更多的原理解读,可以看:AppLruDisk3.5 DataStore分析为何会有DataStoreDataStore 被创造出来的目标就是替代 Sp,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。DataStore优势是异步ApiDataStore 的主要优势之一是异步API,所以本身并未提供同步API调用,但实际上可能不一定始终能将周围的代码更改为异步代码。提出一个问题和思考如果使用现有代码库采用同步磁盘 I/O,或者您的依赖项不提供异步API,那么如何将DataStore存储数据改成同步调用?使用阻塞式协程消除异步差异使用 runBlocking() 从 DataStore 同步读取数据。runBlocking()会运行一个新的协程并阻塞当前线程直到内部逻辑完成,所以尽量避免在UI线程调用。频繁使用阻塞式协程会有问题吗要注意的一点是,不用在初始读取时调用runBlocking,会阻塞当前执行的线程,因为初始读取会有较多的IO操作,耗时较长。更为推荐的做法则是先异步读取到内存后,后续有需要可直接从内存中拿,而非运行同步代码阻塞式获取。3.6 HashMap存储分析内存缓存的场景比如 SharedPreferences 存储中,就做了内存缓存的操作。3.7 Sqlite存储分析注意:缓存的数据库是存放在/data/data/databases/目录下,是占用内存空间的,如果缓存累计,容易浪费内存,需要及时清理缓存。3.8 使用缓存注意点在使用内存缓存的时候须要注意防止内存泄露,使用磁盘缓存的时候注意确保缓存的时效性针对SharedPreferences使用建议有:因为 SharedPreferences 虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。它设计初衷是轻量级,建议当存储文件中key-value数据超过30个,如果超过30个(这个只是一个假设),则开辟一个新的文件进行存储。建议不同业务模块的数据分文件存储……针对MMKV使用建议有:如果项目中有高频率,同步存储数据,使用MMKV更加友好。针对DataStore使用建议有:建议在初始化的时候,使用全局上下文Context给DataStore设置存储路径。针对LruCache缓存使用建议:如果你使用“计量”淘汰策略,需要重写 SystemLruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。3.9 各种数据存储文件SharedPreferences 存储文件格式如下所示<?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <string name="name">杨充</string> <int name="age" value="28" /> <boolean name="married" value="true" /> </map>MMKV 存储文件格式如下所示MMKV的存储结构,分了两个文件,一个数据文件,一个校验文件crc结尾。大概如下所示:这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便MMAP映射,磁盘直接占用了8k的存储。LruDiskCache 存储文件格式如下所示DataStore 存储文件格式如下所示04.通用缓存方案思路4.1 如何兼容不同缓存定义通用的存储接口不同的存储方案,由于api不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api定义一个接口,这个接口有什么呢?主要是存和取各种基础类型数据,比如saveInt/readInt;saveString/readString等通用抽象方法4.2 打造通用缓存Api通用缓存Api设计思路:通用一套api + 不同接口实现 + 代理类 + 工厂模型定义缓存的通用API接口,这里省略部分代码interface ICacheable { fun saveXxx(key: String, value: Int) fun readXxx(key: String, default: Int = 0): Int fun removeKey(key: String) fun totalSize(): Long fun clearData() }基于接口而非实现编程的设计思想将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。4.3 切换不同缓存方式传入不同类型方便创建不同存储方式隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则那么具体该怎么实现呢?看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用api却感知不到影响。public static ICacheable getCacheImpl(Context context, @CacheConstants.CacheType int type) { if (type == CacheConstants.CacheType.TYPE_DISK) { return DiskFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_LRU) { return LruCacheFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_MEMORY) { return MemoryFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_MMKV) { return MmkvFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_SP) { return SpFactory.create().createCache(context); } else if (type == CacheConstants.CacheType.TYPE_STORE) { return StoreFactory.create().createCache(context); } else { return MmkvFactory.create().createCache(context); } }4.4 缓存的过期处理说一个使用场景比如你准备做WebView的资源拦截缓存,针对模版页面,为了提交加载速度。会缓存css,js,图片等资源到本地。那么如何选择存储方案,如何处理过期问题?思考一下该问题比如WebView缓存方案是数据库存储,db文件。针对缓存数据,猜想思路可能是Lru策略,或者标记时间清除过期文件。那么缓存过期处理的策略有哪些定时过期:每个设置过期时间的key都需要创建⼀个定时器,到过期时间就会立即清除。惰性过期:只有当访问⼀个 key 时,才会判断该key是否已过期,过期则清除。定期过期:每隔⼀定的时间,会扫描⼀定数量的数据库的 expires 字典中⼀定数量的key(是随机的), 并 清除其中已过期的key 。分桶策略:定期过期的优化,将过期时间点相近的 key 放在⼀起,按时间扫描分桶。4.5 缓存的阀值处理淘汰一个最早的节点就足够吗?以Lru缓存为案例做分析……标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。那么在LRUCache该如何做呢?在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。LruCache是如何解决这个问题这个地方就需要重写LruCache中的sizeOf()方法,然后拿到key和value对象计算其内存大小。4.6 缓存的线程安全性为何要强调缓存方案线程安全性缓存虽好,用起来很快捷方便,但在使用过程中,大家一定要注意数据更新和线程安全,不要出现脏数据。针对LruCache中使用LinkedHashMap读写不安全情况保证LruCache的线程安全,在put,get等核心方法中,添加synchronized锁。这里主要是synchronized (this){ put操作 }针对DiskLruCache读写不安全的情况DiskLruCache 管理多个 Entry(key-values),因此锁粒度应该是 Entry 级别的。get 和 edit 方法都是同步方法,保证内部的 Entry Map 的安全访问,是保证线程安全的第一步。4.7 缓存数据的迁移如何将Sp数据迁移到DataStore通过属性委托的方式创建DataStore,基于已有的SharedPreferences文件进行创建DataStore。将sp文件名,以参数的形式传入preferencesDataStore,DataStore会自动将该文件中的数据进行转换。val Context.dataStore: DataStore<Preferences> by preferencesDataStore( name = "user_info", produceMigrations = { context -> listOf(SharedPreferencesMigration(context, "sp_file_name")) })如何将sp数据迁移到MMKVMMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。MMKV preferences = MMKV.mmkvWithID("myData"); // 迁移旧数据 { SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE); preferences.importFromSharedPreferences(old_man); old_man.edit().clear().commit(); }思考一下,MMKV框架实现了sp的两个接口,即磨平了数据迁移差异性那么使用这个方式,借鉴该思路,你能否尝试用该方法,去实现LruDiskCache方案的sp数据一键迁移。4.8 缓存数据加密思考一下,如果让你去设计数据的加密,你该怎么做?具体可以参考MMKV的数据加密过程。4.9 缓存效率的对比测试数据测试写入和读取。注意分别使用不同的方式,测试存储或获取相同的数据(数据为int类型数字,还有String类型长字符串)。然后查看耗时时间的长短……比较对象SharePreferences/DataStore/MMKV/LruDisk/Room。使用华为手机测试测试数据案例1在主线程中测试数据,同步耗时时间(主线程还有其他的耗时)跟异步场景有较大差别。测试数据案例2测试1000组长字符串数据,MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。最后思考说明从最终的数据来看,这几种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时。而在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。05.方案基础设计5.1 整体架构图统一存储方案的架构图5.2 UML设计图通用存储方案UML设计图5.3 代码说明图项目中代码相关说明图5.4 关键流程图mmap的零拷贝流程图5.5 模块间依赖关系存储库依赖的关系MMKV需要依赖一些腾讯开源库的服务;DataStore存储需要依赖datastore相关的库;LruDisk存储需要依赖disk库如果你要拓展其他的存储方案,则需要添加其依赖。需要注意,添加的库使用compileOnly。06.其他设计说明6.1 性能设计关于基础库性能如何考量具体性能可以参考测试效率的对比。6.2 稳定性设计针对多进程初始化遇到问题:对于多进程在Application的onCreate创建几次,导致缓存存储库初始化了多次。问题分析:该场景不是该库的问题,建议判断是否是主进程,如果是则进行初始化。如何解决:思路是获取当前进程名,并与主进程对比,来判断是否为主进程。具体可以参考:优雅判断是否是主进程6.3 灰度设计暂无灰度设计6.4 降级设计由于缓存方式众多,在该库中配置了降级,如何设置降级//设置是否是debug模式 CacheConfig cacheConfig = builder.monitorToggle(new IMonitorToggle() { @Override public boolean isOpen() { //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关 return true; } }) //创建 .build(); CacheInitHelper.INSTANCE.init(this,cacheConfig);降级后的逻辑处理是如果是降级逻辑,则默认使用谷歌官方存储框架SharedPreferences。默认是不会降级的!if (CacheInitHelper.INSTANCE.isToggleOpen()){ //如果是降级,则默认使用sp return SpFactory.create().createCache(); }6.5 异常设计说明DataStore初始化遇到的坑遇到问题:不能将DataStore初始化代码写到Activity里面去,否则重复进入Activity并使用Preferences DataStore时,会尝试去创建一个同名的.preferences_pb文件。问题分析:SingleProcessDataStore#check(!activeFiles.contains(it)),该方法会检查如果判断到activeFiles里已经有该文件,直接抛异常,即不允许重复创建。如何解决:在项目中只在顶层调用一次 preferencesDataStore 方法,这样可以更轻松地将 DataStore 保留为单例。MMKV遇到的坑说明MMKV 是有数据损坏的概率的,MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。6.6 兼容性设计MMKV数据迁移比较难MMKV都是按字节进行存储的,实际写入文件把类型擦除了,这也是MMKV不支持getAll的原因,虽然说getAll用的不多问题不大,但是MMKV因此就不具备导出和迁移的能力。比较好的方案是每次存储,多用一个字节来存储数据类型,这样占用的空间也不会大很多,但是具备了更好的可扩展性。6.7 自测性设计MMKV不太方便查看数据和解析数据官方目前支持了5个平台,Android、iOS、Win、MacOS、python,但是没有提供解析数据的工具,数据文件和crc都是字节码,除了中文能看出一些内容,直接查看还是存在大量乱码。比如线上出了问题,把用户的存储文件捞上来,还得替换到系统目录里,通过代码断点去看,这也太不方便了。Sp,FastSp,DiskCache,Store等支持查看文件解析数据傻瓜式的查看缓存文件,操作缓存文件。具体看该库:MonitorFileLib磁盘查看工具07.通用Api设计7.1 如何依赖该库依赖该库如下所示//通用缓存存储库,支持sp,fastsp,mmkv,lruCache,DiskLruCache等 implementation 'com.github.yangchong211.YCCommonLib:AppBaseStore:1.4.8'7.2 初始化缓存库通用存储库初始化CacheConfig.Builder builder = CacheConfig.Companion.newBuilder(); //设置是否是debug模式 CacheConfig cacheConfig = builder.debuggable(BuildConfig.DEBUG) //设置外部存储根目录 .extraLogDir(null) //设置lru缓存最大值 .maxCacheSize(100) //内部存储根目录 .logDir(null) //创建 .build(); CacheInitHelper.INSTANCE.init(MainApplication.getInstance(),cacheConfig); //最简单的初始化 //CacheInitHelper.INSTANCE.init(CacheConfig.Companion.newBuilder().build());7.3 切换各种缓存方案如何调用api切换各种缓存方案//这里可以填写不同的type val cacheImpl = CacheFactoryUtils.getCacheImpl(CacheConstants.CacheType.TYPE_SP)7.4 数据的存和取存储数据和获取数据//存储数据 dataCache.saveBoolean("cacheKey1",true); dataCache.saveFloat("cacheKey2",2.0f); dataCache.saveInt("cacheKey3",3); dataCache.saveLong("cacheKey4",4); dataCache.saveString("cacheKey5","doubi5"); dataCache.saveDouble("cacheKey6",5.20); //获取数据 boolean data1 = dataCache.readBoolean("cacheKey1", false); float data2 = dataCache.readFloat("cacheKey2", 0); int data3 = dataCache.readInt("cacheKey3", 0); long data4 = dataCache.readLong("cacheKey4", 0); String data5 = dataCache.readString("cacheKey5", ""); double data6 = dataCache.readDouble("cacheKey5", 0.0);也可以通过注解的方式存储数据class NormalCache : DataCache() { @BoolCache(KeyConstant.HAS_ACCEPTED_PARENT_AGREEMENT, false) var hasAcceptParentAgree: Boolean by this } //如何使用 object CacheHelper { //常规缓存数据,记录一些重要的信息,慎重清除数据 private val normal: NormalCache by lazy { NormalCache().apply { setCacheImpl( DiskCache.Builder() .setFileId("NormalCache") .build() ) } } fun normal() = normal } //存数据 CacheHelper.normal().hasAcceptParentAgree = true //取数据 val hasAccept = CacheHelper.normal().hasAcceptParentAgree7.5 查看缓存文件数据android缓存路径查看方法有哪些呢?将手机打开开发者模式并连接电脑,在pc控制台输入cd /data/data/目录,使用adb主要是方便测试(删除,查看,导出都比较麻烦)。如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。一键接入该工具FileExplorerActivity.startActivity(this);开源项目地址:https://github.com/yangchong211/YCAndroidTool查看缓存文件数据如下所示7.6 如何选择合适方案比如常见的缓存、浏览器缓存、图片缓存、线程池缓存、或者WebView资源缓存等等那就可以选择LRU+缓存淘汰算法。它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。比如针对高频率,同步存储,或者跨进程等存储数据的场景那就可以选择MMKV这种存储方案。它的核心思想就是高速存储数据,且不会阻塞主线程卡顿。比如针对存储表结构,或者一对多这类的数据那就可以选择DataStore,Room,GreenDao等存储库方案。比如针对存储少量用户类数据其实也可以将json转化为字符串,然后选择sp,mmkv,lruDisk等等都可以。08.其他说明介绍8.1 遇到的坑分析Sp存储数据commit() / apply()操作可能会造成ANR问题commit()是同步提交,会在UI主线程中直接执行IO操作,当写入操作耗时比较长时就会导致UI线程被阻塞,进而产生ANR;apply()虽然是异步提交,但异步写入磁盘时,如果执行了Activity / Service中的onStop()方法,那么一样会同步等待SP写入完毕,等待时间过长时也会引起ANR问题。首先分析一下SharedPreferences源码中apply方法SharedPreferencesImpl#apply(),这个方法主要是将记录的数据同步写到Map集合中,然后在开启子线程将数据写入磁盘SharedPreferencesImpl#enqueueDiskWrite(),这个会将runnable被写入了队列,然后在run方法中写数据到磁盘QueuedWork#queue(),这个将runnable添加到sWork(LinkedList链表)中,然后通过handler发送处理队列消息MSG_RUN然后再看一下ActivityThread源码中的handlePauseActivity()、handleStopActivity()方法。ActivityThread#handlePauseActivity()/handleStopActivity(),Activity在pause和stop的时候会调用该方法ActivityThread#handlePauseActivity()#QueuedWork.waitToFinish(),这个是等待QueuedWork所有任务处理完的逻辑QueuedWork#waitToFinish(),这个里面会通过handler查询MSG_RUN消息是否有,如果有则会waiting等待那么最后得出的结论是handlePauseActivity()的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。但普通存储的场景,这种可能性很小。8.2 项目开发分享通用缓存存储库开源代码https://github.com/yangchong211/YCCommonLib/tree/master/AppBaseStore
数据结构-Hash常见操作实践目录介绍01.什么是哈希算法02.哈希算法的应用03.安全加密的场景04.唯一标识的场景05.数据校验的场景06.散列函数的场景07.Git版本的控制08.云存储文件场景09.哈希算法的总结10.哈希算法的特点11.哈希算法的实践12.常用哈希码算法13.Map哈希的算法14.理解HashCode15.哈希冲突的解决16.问题思考的答疑01.什么是哈希算法哈希算法历史悠久业界著名的哈希算法也很多,比如MD5、SHA等。在平时的开发中,基本上都是拿现成的直接用。今天不会重点剖析哈希算法的原理,也不会教你如何设计一个哈希算法,而是从实战角度告诉你,在实际开发中,我们该如何用哈希算法解决问题。什么是哈希算法,用一句话就可以概括了。将任意长度的二进制值串映射为固定长度的二进制值串,这个映射规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。但是,要设计一个优秀的哈希算法并不容易,我了需要满足的几点要求:从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);对输入数据非常敏感,哪怕原始数据只修改了一个Bit,最后得到的哈希值也大不相同;散列总被的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;哈希算法的执行效率尽量高效,针对较长的文本,也能快速计算出哈希值。拿MD5这种哈希算法具体说明下,比如计算这两个文本的MD5哈希值——“今天我来讲哈希算法”、“jiajia"。得到的两串毫无规律的字符串(MD5的哈希值是128位的Bit长度,便于表示,转化为16进制编码)。可以看出,无论文本的长度是多少,得到的哈希值长度是相同的,而且看起来像一堆随机数,完全没有规律。MD5("今天我来讲哈希算法") = bb4767201ad42c74e650c1b6c03d78fa MD5("jiajia") = cd611a31ea969b908932d44d126d195b试试两个很相似的文本,虽然只有一个标点的差别,但哈希值是完全不相同的。同时根据哈希值,是很难反向推导出原始数据。MD5("我今天讲哈希算法!") = 425f0d5a917188d2c3c3dc85b5e4f2cb MD5("我今天讲哈希算法 ") = a1fb91ac128e6aa37fe42c663971ac3d哈希算法要处理的文本可能是各种各样的。比如,对于非常长的文本,如果哈希算法的计算时间很长,那就只能停留在理论研究的层面,很难应用到实际软件开发中。比如,把今天的这篇包含4000多个汉字的文章,用MD5计算哈希值,用不了1ms的时间。02.哈希算法的应用Hash有哪些流行的算法目前流行的 Hash 算法包括 MD5、SHA-1 和 SHA-2。哈希算法主要有哪些MD5算法:MD5,MD5+盐SHA算法:包含5个算法,分别是SHA-1、SHA-224、SHA-256、SHA-384和SHA-512,后四者并称为SHA-2。哈希算法的应用非常非常多,选了最觉的七个分别是安全加密、唯一标识、数据校验、散列函数、Git版本控制、云存储、数据分片。03.安全加密的场景说到哈希算法的应用,最先想到的应该是安全加密。最常用于加密的哈希算法是MD5(MD5 Message-Digest Algorithm,MD5消息摘要算法)和SHA(Secure Hash Algorithm,安全散列算法)。除了这两个之外,当然还有很多其他的加密方法,比如DES(Advance Encryption Standard,高级加密标准)。对用于加密的哈希算法来说,有两点很重要:第一是很难根据哈希值反向推导出原始数据,第二是散列冲突的概率要很小。第一点很好理解,加密的目的就是不会后悔原始数据泄露,所以很难通过哈希值反向推导出原始以数据,这是一个基本要求。重点说说第二点,但不管什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的,这是为什么呢?基于组合数学中一个叛党基础的理论,鸽巢原理(也叫抽屉原理)。这个原理本身很简单,它是说,如果有10个鸽巢,有11只鸽子,那肯定有1个鸽巢中的鸽子数量大于1,换句话说就是,肯定有一个巢里的鸽子数量大于1。哈希算法产生的哈希值的长度是固定且有限的。比如前面说的MD5的鸽子,哈希值是固定的128位二进制串,能表示的数据是有限的,最多表示2^128个数据,而我们要哈希的数据可以是无穷的,那必然会存在哈希值相同的情况。如果我们拿到一个MD5哈希值,希望通过毫无规律的穷举的方法,找到这个MD5值相同的另一个数据,那耗费的时间应该是个天文数字了。即便哈希算法理论上存在冲突,但还是很难破解的。除此之外,没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如SHA-256比SHA-1要更复杂、更安全,相应的计算时间就会比较长。04.唯一标识的场景先举个例子。如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来对比,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?任何文件在计算机中都可以表示成二进制码串,所以,比较笨的办法就是,拿要查找的图片的二进制码串与图库中所有图片的二进制码串逐一比对。如果相同,则说明图片在图库中存在。但是,每个图片小则几十KB、大则几MB,转化成二进制是一个非常长的串,比对起来非常耗时。有没有比较快的方法呢?可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片二进制码串开关取100个字节,从中间取100个字节,从最后取100个字节,然后将这300个字节放一块。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个标识。如果不存在,那就说明这个图片不在图库中,如果存在,我们再通过散列表存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样,如果一样,就说明已经存在;如果一一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。05.数据校验的场景电驴这样的BT下载软件听过吧!BT下载的原理是基石地P2P协议的。我们从多个机器上并行下载一个2GB的电影,这个电影文件可能会被分割成很多文件块(比如可以分成100块,每块大约200MB)。等所有的文件块都下载完成之后,再组装成一个完整的电影文件就行了。网络传输是不安全的,下载的文件块有可能是被宿主机恶意修改过的,又或者下载过程中出现了错误,所以下载的文件块可能不是完整的。如果我们没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。现在的问题是,如何来校验文件块的安全、正确、完整呢?具体的BT协议很复杂,校验方法也有很多,我来说其中的一种思路。我们通过哈希算法,对100个文件块分别取哈希值,并且保存种子文件中。在前面讲过,哈希算法有一个特点,对数据很敏感。只要文件块内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机上下载这个文件块。06.散列函数的场景散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便是出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀的散列到各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,能以,散列函数用的散列算法一般都比较简单,比较追求效率。最常见的散列函数应用场景比如工业存储key-value集合HashMap数据结构,存储key就用到了散列函数!HashMap为何对key使用哈希算法hash值(key)存在的目的是加速键值对的查找,key的作用是为了将元素适当地放在各个桶里,对于抗碰撞的要求没有那么高。07.Git版本的控制以Git为代表的众多版本控制工具都在使用SHA1等散列函数检查文件更新包括GitHub在内的众多版本控制工具以及各种云同步服务都是用SHA1来区别文件,很多安全证书或是签名也使用SHA1来保证唯一性。长期以来,人们都认为SHA1是十分安全的,至少大家还没有找到一次碰撞案例。08.云存储文件场景现在大部分的网络部署和版本控制工具都在使用散列算法来保证文件可靠性。在进行文件系统同步、备份等工具时,使用散列算法来标志文件唯一性能帮助我们减少系统开销,这一点在很多云存储服务器中都有应用。当原有文件发生改变时,其标志值也会发生改变,从而告诉文件使用者当前的文件已经不是你所需求的文件。散列函数很难可逆这种不可逆性体现在,你不仅不可能根据一段通过散列算法得到的指纹来获得原有的文件,也不可能简单地创造一个文件并让它的指纹与一段目标指纹相一致。09.哈希算法的总结第一个应用是唯一标识,哈希算法可以对大数据做信息摘要,通过一个较短的二进制编码来表示很大的数据。第二个应用是校验数据的完整性和正确性。第三个应用是安全加密,任何哈希算法都会出现散列冲突,但是这个冲突的概率非常小。越是复杂的哈希算法越难破解,但同样计算时间也就越长。所以,选择哈希算法的时候,要权衡安全性和计算时间来决定用哪种哈希算法。第四个应用是散列函数,这个我们前面讲散列表的时候详细说过,它对哈希算法的要求非常特别,更加看重的是散列的平均性和哈希算法的执行效率。10.哈希算法的特点正向快速:给定明文和 hash 算法,在有限时间和有限资源内能计算出 hash 值。逆向困难:给定(若干) hash 值,在有限时间内很难(基本不可能)逆推出明文。输入敏感:原始输入信息修改一点信息,产生的 hash 值看起来应该都有很大不同。冲突避免:很难找到两段内容不同的明文,使得它们的 hash 值一致(发生冲突)。即对于任意两个不同的数据块,其hash值相同的可能性极小;对于一个给定的数据块,找到和它hash值相同的数据块极为困难。11.哈希算法的实践提供几个简单的概念供大家参考作为散列算法,首要的功能就是要使用一种算法把原有的体积很大的文件信息用若干个字符来记录,还要保证每一个字节都会对最终结果产生影响。那么大家也许已经想到了,求模这种算法就能满足我们的需要。事实上,求模算法作为一种不可逆的计算方法,已经成为了整个现代密码学的根基。只要是涉及到计算机安全和加密的领域,都会有模计算的身影。散列算法也并不例外,一种最原始的散列算法就是单纯地选择一个数进行模运算,比如以下程序。# 构造散列函数 def hash(a): return a % 8 # 测试散列函数功能 print(hash(233)) print(hash(234)) print(hash(235)) 123上述的程序完成了一个散列算法所应当实现的初级目标:用较少的文本量代表很长的内容(求模之后的数字肯定小于8)。但也许你已经注意到了,单纯使用求模算法计算之后的结果带有明显的规律性,这种规律将导致算法将能难保证不可逆性。所以我们将使用另外一种手段,那就是异或。在散列函数中加入一个异或过程# 构造散列函数 def hash(a): return (a % 8) ^ 5 # 测试散列函数功能 print(hash(233)) print(hash(234)) print(hash(235)) # 输出结果 - 4 - 7 - 6很明显的,加入一层异或过程之后,计算之后的结果规律性就不是那么明显了。如果用户使用连续变化的一系列文本与计算结果相比对,就很有可能找到算法所包含的规律。在进行计算之前对原始文本进行修改,或是加入额外的运算过程(如移位)# 构造散列函数 def hash(a): return (a + 2 + (a << 1)) % 8 ^ 5 # 测试散列函数功能 print(hash(233)) print(hash(234)) print(hash(235)) # 输出结果 - 0 - 5 - 6这样处理得到的散列算法就很难发现其内部规律上面的算法是不是很简单?事实上,常用算法MD5和SHA1,其本质算法就是这么简单,只不过会加入更多的循环和计算,来加强散列函数的可靠性。12.常用哈希码算法下面给出在Java中几个常用的哈希码(hashCode)的算法。Object类的hashCode. 返回对象的经过处理后的内存地址,由于每个对象的内存地址都不一样,所以哈希码也不一样。这个是native方法,取决于JVM的内部设计,一般是某种C地址的偏移。String类的hashCode. 根据String类包含的字符串的内容,根据一种特殊算法返回哈希码,只要字符串的内容相同,返回的哈希码也相同。Integer等包装类,返回的哈希码就是Integer对象里所包含的那个整数的数值,例如Integer i1=new Integer(100), i1.hashCode的值就是100 。由此可见,2个一样大小的Integer对象,返回的哈希码也一样。int,char这样的基础类,它们不需要hashCode,如果需要存储时,将进行自动装箱操作,计算方法同上。13.Map哈希的算法对key进行Hash计算在JDK8中,由于使用了红黑树来处理大的链表开销,所以hash这边可以更加省力了,只用计算hashCode并移动到低位就可以了。static final int hash(Object key) { int h; //计算hashCode,并无符号移动到低位 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }举个例子: 363771819^(363771819 >>> 16)。这样做可以实现了高地位更加均匀地混到一起。0001 0101 1010 1110 1011 0111 1010 1011(363771819) 0000 0000 0000 0000 0001 0101 1010 1110(5550) XOR --------------------------------------- = 0001 0101 1010 1110 1010 0010 0000 0101(363766277)获取到数组的index的位置。计算了Hash,我们现在要把它插入数组中了//tab:是Node<K,V>[] tab int index = (tab.length - 1) & hash;通过位运算,确定了当前的位置,因为HashMap数组的大小总是2^n,所以实际的运算就是 (0xfff…ff) & hash ,这里的tab.length-1相当于一个mask,滤掉了大于当前长度位的hash,使每个i都能插入到数组中。这个对象是一个包装类,Nodestatic class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; //getter and setter .etc. }插入包装类到数组。如果输入当前的位置是空的,就插进去,如图,左为插入前,右为插入后 0 0 | | 1 -> null 1 - > null | | 2 -> null 2 - > null | | ..-> null ..- > null | | i -> null i - > new node | | n -> null n - > null如果当前位置已经有了node,且它们发生了碰撞,则新的放到前面,旧的放到后面,这叫做链地址法处理冲突。可以发现,失败的hashCode算法会导致HashMap的性能由数组下降为链表,所以想要避免发生碰撞,就要提高hashCode结果的均匀性。 0 0 | | 1 -> null 1 - > null | | 2 -> null 2 - > null | | ..-> null ..- > null | | i -> old i - > new - > old | | n -> null n - > null14.理解HashCodeHashCode也是哈希算法的一种HashCode是Object的一个方法,hashCode方法返回一个hash code值,且这个方法是为了更好的支持hash表,比如String,Set,HashTable、HashMap等;HashCode的意义是什么如果用 equal 去比较的话,如果存在1000个元素,你 new 一个新的元素出来,需要去调用1000次equal去逐个和他们比较是否是同一个对象,这样会大大降低效率。hashcode实际上是返回对象的存储地址,如果这个位置上没有元素,就把元素直接存储在上面,如果这个位置上已经存在元素,这个时候才去调用equal方法与新元素进行比较,这样大大提高效率。HashCode的作用减少查找次数,提高程序效率。例如查找是否存在重复值h(k1)≠h(k2)则k1≠k2首先查看h(k2)输出值(内存地址),查看该内存地址是否存在值;如果无,则表示该值不存在重复值;如果有,则进行值比较,相同则表示该值已经存在散列列表中,如果不相同则再进行一个一个值比较;而无需一开始就一个一个值的比较,减少了查找次数用hashcode判断两个对象是否相等可以吗肯定是不可以的,因为不同的对象可能会生成相同的hashcode值。虽然不能根据hashcode值判断两个对象是否相等,但是可以直接根据hashcode值判断两个对象不等,如果两个对象的hashcode值不等,则必定是两个不同的对象。如果要判断两个对象是否真正相等,必须通过equals方法。思考一下下面问题使用HashMap存储对象,对key进行哈希算法,可能会出现碰撞,那么如何解决碰撞呢?15.哈希冲突的解决什么是哈希冲突对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),这种现象称hash冲突。即:key1通过f(key1)得到散列地址去存储key1,同理,key2发现自己对应的散列地址已经被key1占据了。解决办法(总共有四种):1.开放寻址法所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入 。开放寻址法:Hi=(H(key) + di) MOD m,i=1,2,…,k(k<=m-1),其中H(key)为散列函数,m为散列表长,di为增量序列,可有下列三种取法:1).di=1,2,3,…,m-1,称线性探测再散列;2).di=1^2,(-1)^2,2^2,(-2)^2,(3)^2,…,±(k)^2,(k<=m/2)称二次探测再散列;3).di=伪随机数序列,称伪随机探测再散列。用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术(线性探测法、二次探测法(解决线性探测的堆积问题)、随机探测法(和二次探测原理一致,不一样的是:二次探测以定值跳跃,而随机探测的散列地址跳跃长度是不定值))在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止插入即可。2.再哈希再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,….,等哈希函数去计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。3.链地址法(Java HashMap就是这么做的)链地址法的基本思想是:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,将所有关键字为同义词的结点链接在同一个单链表中。4.建立一个公共溢出区这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。16.问题思考的答疑1.如何防止数据库中的用户信息被脱库?你会如何存储用户密码这么重要的数据吗?一.使用MD5进行加密二.字典攻击:如果用户信息被“脱库”,黑客虽然拿到的是加密之后的密文,但可以通过“猜”的方式来破解密码,这是因为,有些用户的密码太简单。三.针对字典攻击,我们可以引入一个盐(salt),跟用户密码组合在一起,增加密码的复杂度。四.最好对密码验证次数进行限时间段限制。2.在实际开发中,我们应该如何用哈希算法解决问题?在实际开发中要权衡破解难度和计算时间来决定究竟使用哪种加密算法。3.为何银行密码6个数字不易破解用户设置一个简单密码,进行加密后就变成32位,64位,128位等密码了,然后在网上传输就安全多了,一般这种加密密码和时间戳也正相关。截获了也用处不大,就是把时间参数传递过去了,服务器也和本地时间比对的,超过3分钟他们就认为是非法消息,它是不断变化的,破解很困难。很多网站都有输入次数限制,所以对很多网站的密码破解都集中在加密算法上,很少进行字典式攻击了,当然黑客找到网站的漏洞,绕过次数限制,也会进行字典式轰炸。综合库:https://github.com/yangchong211/YCAppTool视频播放器:https://github.com/yangchong211/YCVideoPlayer综合博客汇总:https://github.com/yangchong211/YCBlogs
目录介绍01.整体概述介绍1.1 项目背景1.2 思考问题1.3 设计目标1.4 收益分析02.市面抓包的分析2.1 Https三要素2.2 抓包核心原理2.3 搞定CA证书2.4 突破CA证书校验2.5 如何搞定加解密2.6 Charles原理2.7 抓包原理图2.8 抓包核心流程03.防止抓包思路3.1 先看如何抓包3.2 设置配置文件3.3 数据加密处理3.4 避免黑科技抓包04.防抓包实践开发4.1 App安全配置4.2 关闭代理4.3 证书校验4.4 双向认证4.5 防止挂载抓包4.6 数据加解密4.7 证书锁定4.8 Sign签名4.9 其他的方式05.架构设计说明5.1 整体架构设计5.2 关键流程图5.3 稳定性设计5.4 降级设计5.5 异常设计说明06.防抓包功能自测6.1 网络请求测试6.2 抓包测试6.3 黑科技挂载测试6.4 逆向破解测试01.整体概述介绍1.1 项目背景通讯安全是App安全检测过程中非常重要的一项针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。保证数据安全通过charles等工具可以对app的网络请求进行抓包,这样这些信息就会被清除的提取出来,会被不法分子进行利用。不想被竞争对手逆向抓包不想自身App的数据被别人轻而易举地抓包获取到,从而进行类似业务或数据分析、爬虫或网络攻击等破坏性行为。1.2 思考问题开发项目的时候,都需要抓包,很多情况下即使是Https也能正常抓包正常。那么问题来了:抓包的原理是?任何Https的 app 都能抓的到吗?如果不能,哪些情况下可以抓取,哪些情况下抓取不到?什么叫做中间人攻击?使用HTTPS协议进行通信时,客户端需要对服务器身份进行完整性校验,以确认服务器是真实合法的目标服务器。如果没有校验,客户端可能与仿冒的服务器建立通信链接,即“中间人攻击”。1.3 设计目标防止App被各种方式抓包做好各种防抓包安全措施,避免各种黑科技抓包。沉淀为技术库复用目前只是针对App端有需要做防抓包措施,后期其他业务线可能也有这个需要。因此下沉为工具库,傻瓜式调用很有必要。该库终极设计目标如下所示第一点:必须是低入侵性,对原有代码改动少,最简单的加入是一行代码设置即可。完全解耦合。第二点:可以动态灵活配置,支持配置禁止代理,支持配置是否证书校验,支持配置域名合法性过滤,支持拦截器加解密数据。第三点:可以检测App是否在双开,挂载,Xposed攻击环境第四点:可以灵活设置加解密的key,可以灵活替换加解密方式,比如目前采用RC4,另一个项目想用DES,可以灵活更换。1.4 收益分析抓包库收益提高产品App的数据安全,必须对数据传输做好安全保护措施和完整性校验,以防止自身数据在网络传输中裸奔,甚至是被三方恶意利用或攻击。技能的收益下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。傻瓜式调用,低成本接入!02.市面抓包的分析2.1 Https三要素要清楚HTTPS抓包的原理,首先需要先说清楚 HTTPS 实现数据安全传输的工作原理,主要分为三要素和三阶段。Http传输数据目前存在的问题1.通信使用明文,内容可能被窃听;2.不验证通信方的身份,因此可能遭遇伪装;3.无法证明报文的完整性,所以有可能遭到篡改。Https三要素分别是:1.加密:通过对称加密算法实现。2.认证:通过数字签名实现。(因为私钥只有 “合法的发送方” 持有,其他人伪造的数字签名无法通过验证)3.报文完整性:通过数字签名实现。(因为数字签名中使用了消息摘要,其他人篡改的消息无法通过验证)Https三阶段分别是:1.CA 证书校验:CA 证书校验发生在 TLS 的前两次握手,客户端和服务端通过报文获得服务端 CA 证书,客户端验证 CA 证书合法性,从而确认 CA 证书中的公钥合法性(大多数场景不会做双向认证,即服务端不会认证客户端合法性,这里先不考虑)。2.密钥协商:密钥协商发生在 TLS 的后两次握手,客户端和服务端分别基于公钥和私钥进行非对称加密通信,协商获得 Master Secret 对称加密私钥(不同算法的协商过程细节略有不同)。3.数据传输:数据传输发生在 TLS 握手之后,客户端和服务端基于协商的对称密钥进行对称加密通信。Https流程图如下2.2 抓包核心原理HTTPS抓包原理Fiddler、Charles等抓包工具,其实都是采用了中间人攻击的方案: 将客户端的网络流量代理到MITM(中间人)主机,再通过一系列的面板或工具将网络请求结构化地呈现出来。抓包Https有两个突破点CA证书校验是否合法;数据传递过程中的加密和解密。如果是要抓包,则需要突破这两点的技术,无非就是MITM(中间人)伪造证书和使用自己的加解密方式。抓包的工作流程如下中间人截获客户端向发起的HTTPS请求,佯装客户端,向真实的服务器发起请求;中间人截获真实服务器的返回,佯装真实服务器,向客户端发送数据;中间人获取了用来加密服务器公钥的非对称秘钥和用来加密数据的对称秘钥,处理数据加解密。2.3 搞定CA证书Https抓包核心CA证书HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥。前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。安装CA证书到手机中必须洗白抓包应用内置的 CA 证书要洗白,必须安装到系统中。而 Android 系统将 CA 证书又分为两种:用户 CA 证书和系统 CA 证书(必要Root权限)。Android从7.0开始限制CA证书只有系统(system)证书才会被信任。用户(user)导入的Charles根证书是不被信任的。相当于可以理解Android系统增加了安全校验!如何绕过CA证书这种限制呢?已知有以下四种方式第一种方式:AndroidManifest 中配置 networkSecurityConfig,App 信任用户 CA 证书,让系统对用户 CA 证书的校验给予通过。第二种方式:调低 targetSdkVersion < 24,不过这种方式谷歌市场有限制,意味着抓 HTTPS 的包越来越难操作。第三种方式:挂载App抓包,VirtualApp 这种多开应用可以作为宿主系统来运行其它应用,利用xposed避开CA证书校验。第四种方式:Root手机,把 CA 证书安装到系统 CA 证书目录中,那这个假 CA 证书就是真正洗白了,难度较大。2.4 突破CA证书校验App版本如何让证书校验安全1.设置targetSdkVersion大于24,去掉清单文件中networkSecurityConfig文件中的system和user配置,设置不信任用户证书。2.公钥证书固定。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。证书固定的一般做法是,将公钥证书(.crt 或者 .cer 等格式)内置到 App 中,然后创建 TrustManager 时将公钥证书加进去。那么如何突破CA证书校验第一种:JustTrustMe 破解证书固定。Xposed 和 Magisk 都有相应的模块,用来破解证书固定,实现正常抓包。破解的原理大致是,Hook 创建 SSLContext 等涉及 TrustManager 相关的方法,将固定的证书移除。第二种:基于 VirtualApp 的 Hook 机制破解证书固定。在 VirtualApp 中加入 Hook 代码,然后利用 VirtualApp 打开目标应用进行抓包。具体看:VirtualHook2.5 如何搞定加解密目前使用对称加密和解密请求和响应数据加密和解密都是用相同密钥。只有一把密钥,如果密钥暴露,内容就会暴露。但是这一块逆向破解有些难度。而破解解密方式就是用密钥逆向解密,或者中间人冒充使用自己的加解密方式!加密后数据镇兼顾了安全性吗不一定安全。中间人伪造自己的公钥和私钥,然后拦截信息,进行篡改。2.6 Charles原理Charles类似代理服务器Charles 通过将软件本身设置成系统的网络访问代理服务器,使得所有的网络请求都会走一遍 Charles 代理,从而 Charles 可以截取经过它的请求,然后我们就可以对其进行网络包的分析。截取设备网络封包数据Charles对应设置:将代理功能打开,并设置一个固定的端口。默认情况下,端口号为:8888 。移动设备设置:在手机上设置 WIFI 的 HTTP 代理。注意这里的前提是,Phone 和 Charles 代理设备链接的是同一网络(同一个ip地址和端口号)。截取Https的网络封包正常情况下,Charles 是不能截取Https的网络包的,这涉及到 Https 的证书问题。2.7 抓包原理图Charles抓包原理图Android上的网络抓包原来是这样工作的Charles抓包2.8 抓包核心流程抓包核心流程关键节点第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢?服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。03.防止抓包思路3.1 先看如何抓包使用Charles需要做哪些操作1.电脑上需要安装证书。这个主要是让Charles充当中间人,颁布自己的CA证书。2.手机上需要安装证书。这个是访问Charles获取手机证书,然后安装即可。3.Android项目代码设置兼容。Google 推出更加严格的安全机制,应用默认不信任用户证书(手机里自己安装证书),自己的app可以通过配置解决,相当于信任证书的一种操作!尤其可知抓包的突破口集中以下几点第一点:必须链接代理,且跟Charles要具有相同ip。思路:客户端是否可以判断网络是否被代理了。第二点:CA证书,这一块避免使用黑科技hook证书校验代码,或者拥有修改CA证书权限。思路:集中在可以判断是否挂载。第三点:冒充中间人CA证书,在客户端client和服务端server之间篡改拦截数据。思路:可以做CA证书校验。第四点:为了可以在7.0上抓包,App往往配置清单文件networkSecurityConfig。思路:线上环境去掉该配置。3.2 设置配置文件一个是CA证书配置文件debug包为了能够抓包,需要配置networkSecurityConfig清单文件的system和user权限,只有这样才会信任用户证书。一个是检验证书配置不论是权威机构颁发的证书还是自签名的,打包一份到 app 内部,比如存放在 asset 里。然后用这个KeyStore去引导生成的TrustManager来提供证书验证。一个是检验域名合法性Android允许开发者重定义证书验证方法,使用HostnameVerifier类检查证书中的主机名与使用该证书的服务器的主机名是否一致。如果重写的HostnameVerifier不对服务器的主机名进行验证,即验证失败时也继续与服务器建立通信链接,存在发生“中间人攻击”的风险。如何查看CA证书的数据证书验证网站 ;SSL配置检查网站3.3 数据加密处理网络数据加密的需求为了项目数据安全性,对请求体和响应体加密,那肯定要知道请求体或响应体在哪里,然后才能加密,其实都一样不论是加密url里面的query内容还是加密body体里面的都一样。对数据哪里进行加密和解密目前对数据返回的data进行加解密。那么如何做数据加密呢?目前项目中采用RC4加密和解密数据。抓取到的内容为乱码有的APP为了防止抓取,在返回的内容上做了层加密,所以从Charles上看到的内容是乱码。这种情况下也只能反编译APP,研究其加密解密算法进行解密。难度极大!3.4 避免黑科技抓包基于Xposed(或者)黑科技破解证书校验这种方式可以检查是否有Xposed环境,大概的思路是使用ClassLoader去加载固定包名的xp类,或者手动抛出异常然后捕获去判断是否包含Xposed环境。基于VirtualApp挂载App突破证书访问权限这个VirtualApp相当于是一个宿主App(可以把它想像成桌面级App),它突破证书校验。然后再实现挂载App的抓包。判断是否是双开环境!04.防抓包实践开发4.1 App安全配置添加配置文件android:networkSecurityConfig="@xml/network_security_config"配置networkSecurityConfig抓包说明中间人代理之所有能够获取到加密密钥就是因为我们手机上安装并信任了其代理证书,这类证书安装后都会被归结到用户证书一类,而不是系统证书。那我们可以选择只信任系统内置的系统证书,而屏蔽掉用户证书(Android7.0以后就默认是只信任系统证书了),就可以防止数据被解密了。实现App防抓包安全配置方式有两种:一种是Android官方提供的网络安全配置;另一种也可以通过设置网络框架实现(以okhttp为例)。第一种:具体可以看清单配置文件,相当于base-config标签下去掉 这组标签。第二种:需要给okhttpClient配置 X509TrustManager 来监听校验服务端证书有效性。遍历设备上信任的证书,通过证书别名将用户证书(别名中含有user字段)过滤掉,只将系统证书添加到验证列表中。该方案优点和缺点分析说明优点:network_security_config配置简单,对整个app网络生效,无需修改代码;代码实现对通过该网络框架请求的生效,能兼容7.0以前系统。缺陷:network_security_config配置方式,7.0以前的系统配置不生效,依然可以通过代理工具进行抓包。okhttp配置的方式只能对使用该网络框架进行数据传输的接口生效,并不能对整个app生效。破解:将手机进行root,然后将代理证书放置到系统证书列表内,就可以绕过代码或配置检查了。4.2 关闭代理charles 和 fiddler 都使用代理来进行抓包,对网络客户端使用无代理模式即可防止抓包,如OkHttpClient.Builder() .proxy(Proxy.NO_PROXY) .build()no_proxy实际上就是type属性为direct的一个proxy对象,这个type有三种direct,http,socks。这样因为是直连,所以不走代理。所以charles等工具就抓不到包了,这样一定程度上保证了数据的安全,这种方式只是通过代理抓不到包。通常情况下上述的办法有用,但是无法防住使用 VPN 导流进行的抓包使用VPN抓包的原理是,先将手机请求导到VPN,再对VPN的网络进行Charles的代理,绕过了对App的代理。该方案优点和缺点分析说明优点:实现简单方便,无系统版本兼容问题。缺陷:该方案比较粗暴,将一切代理都切断了,对于有合理诉求需要使用网络代理的场景无法满足。破解:使用ProxyDroid全局代理工具通过iptables对请求进行强制转发,可以有效绕过代理检测。4.3 证书校验(单向认证)下载服务器端公钥证书为了防止上面方案可能导致的“中间人攻击”,可以下载服务器端公钥证书,然后将公钥证书编译到Android应用中一般在assets文件夹保存,由应用在交互过程中去验证证书的合法性。如何设置证书校验通过OkHttp的API方法 sslSocketFactory(sslSocketFactory,trustManager) 设置SSL证书校验。如何设置域名合法性校验通过OkHttp的API方法 hostnameVerifier(hostnameVerifier) 设置域名合法性校验。证书校验的原理分析按CA证书去验证的,若不是CA可信任的证书,则无法通过验证。单向认证流程图该方案优点和缺点分析说明优点:安全性比较高,单向认证校验证书在代码中是方便的,安全性相对较高。缺陷:CA证书存在过期的问题,证书升级。破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效。4.4 双向认证什么叫做双向认证SSL/TLS 协议提供了双向认证的功能,即除了 Client 需要校验 Server 的真实性,Server 也需要校验 Client 的真实性。双向认证的原理双向认证需要 Server 支持,Client 必须内置一套公钥证书 + 私钥。在 SSL/TLS 握手过程中,Server 端会向 Client 端请求证书,Client 端必须将内置的公钥证书发给 Server,Server 验证公钥证书的真实性。用于双向认证的公钥证书和私钥代表了 Client 端身份,所以其是隐秘的,一般都是用 .p12 或者 .bks 文件 + 密钥进行存放。代码层面如何做双向认证双向校验就是自定义生成客户端证书,保存在服务端和客户端,当客户端发起请求时在服务端也校验客户端的证书合法性,如果不是可信任的客户端发送的请求,则拒绝响应。服务端根据自身使用语言和网络框架配置相应证书校验机制即可。双向认证流程图该方案优点和缺点分析说明优点:安全性非常高,使用三方工具不易破解。缺陷:服务端需要存储客户端证书,一般服务端会对应多个客户端,就需要分别存储和校验客户端证书,增加校验成本,降低响应速度。该方案比较适合对安全等级要求比较高的业务(如金融类业务)。破解:由于在服务端也做校验,在服务端安全的情况下很难被攻破。4.5 防止挂载抓包Xposed是一个牛逼的黑科技Xposed + JustTrustMe 可以破解绕过校验CA证书。那么这样CA证书的校验就形同虚设了,对App的危险性也很大。App多开运行在多个环境上多开App的原理类似,都是以新进程运行被多开的App,并hook各类系统函数,使被多开的App认为自己是一个正常的App在运行。一种是从多开App中直接加载被多开的App,如平行空间、VirtualApp等,另一种是让用户新安装一个App,但这个App本质上就是一个壳,用来加载被多开的App。VirtualApp是一个牛逼的黑科技它破坏了Android 系统本身的隔离措施,可以进行免root hook和其他黑科技操作,你可以用这个做很多在原来APP里做不到事情,于此同时Virtual App的安全威胁也不言而喻。如何判断是否具有Xposed环境第一种方式:获取当前设备所有运行的APP,根据安装包名对应用进行检测判断是否有Xposed环境。第二种方式:通过自造异常来检测堆栈信息,判断异常堆栈中是否包含Xposed等字符串。第三种方式:通过ClassLoader检查是否已经加载了XposedBridge类和XposedHelpers类来检测。第四种方式:获取DEX加载列表,判断其中是否包含XposedBridge.jar等字符串。第五种方式:检测Xposed相关文件,通过读取/proc/self/maps文件,查找Xposed相关jar或者so文件来检测。如何判断是否是双开环境第一种方式:通过检测app私有目录,多开后的应用路径会包含多开软件的包名。还有一种思路遍历应用列表如果出现同样的包名,则被认为双开了。第二种方式:如果同一uid下有两个进程对应的包名,在"/data/data"下有两个私有目录,则该应用被多开了。判断了具有xposed或者多开环境怎么处理App目前使用VirtualApp挂载,或者Xposed黑科技去hook,前期可以先用埋点统计。测试学而思App发现挂载在VA上是推出App。4.5 数据加解密针对数据加解密入口目前在网络请求类里添加拦截器,然后在拦截器中处理request请求和response响应数据的加密和解密操作。主要是加密什么数据在request请求数据阶段,如果是get请求加密url数据,如果是post请求则加密url数据和requestBody数据。在response响应数据阶段,如何进行加密:发起请求(加密)第一步:获取请求的数据。主要是获取请求url和requestBody,这一块需要对数据一块处理。第二步:对请求数据进行加密。采用RC4加密数据第三步:根据不同的请求方式构造新的request。使用 key 和 result 生成新的 RequestBody 发起网络请求如何进行解密:接收返回(解密)第一步:常规解析得到 result ,然后使用RC4工具,传入key去解密数据得到解密后的字符串第二步:将解密的字符串组装成ResponseBody数据传入到body对象中第三步:利用response对象去构造新的response,然后最后返回给App4.7 证书锁定证书锁定是Google官方比较推荐的一种校验方式原理是在客户端中预先设置好证书信息,握手时与服务端返回的证书进行比较,以确保证书的真实性和有效性。如何实现证书锁定有两种实现方式:一种通过network_security_config.xml配置,另一种通过代码设置;//第一种方式:配置文件 <network-security-config> <domain-config> <domain includeSubdomains="true">api.zuoyebang.cn</domain> <pin-set expiration="2025-01-01"> <pin digest="SHA-256">38JpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhK90=</pin> <!-- 备用证书信息,一般为域名证书的二级证书 --> <pin digest="SHA-256">9k1a0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM90K=</pin> </pin-set> </domain-config> </network-security-config> //第二种方式:代码设置 fun sslPinning(): OkHttpClient { val builder = OkHttpClient.Builder() val pinners = CertificatePinner.Builder() .add("api.zuoyebang.cn", "sha256//89KpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRh00L=") .add("api.zuoyebang.com", "sha256//a8za0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1o=09") .build() builder.apply { certificatePinner(pinners) } return builder.build() }该方案优点和缺点分析说明优点:安全性高,配置方式也比较简单,并能实现动态更新配置。缺陷:网络安全配置无法实现证书证书的动态更新,另外该配置也受Android系统影响,对7.0以前的系统不支持。代码配置相对灵活些。破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效4.8 Sign签名先说一下背景和问题http://api.test.com/getbanner?key1=value1&key2=value2&key3=value3这种方式简单粗暴,通过调用getbanner方法即可获取轮播图列表信息,但是这样的方式会存在很严重的安全性问题,没有进行任何的验证,大家都可以通过这个方法获取到数据,导致产品信息泄露。在写开放的API接口时是如何保证数据的安全性的?请求来源(身份)是否合法?请求参数被篡改?请求的唯一性(不可复制)?问题的解决方案设想解决方案:为了保证数据在通信时的安全性,我们可以采用参数签名的方式来进行相关验证。最终决定的解决方案调用接口之前需要验证签名和有效时间,要生成一个sign签名。先拼接-后转码-再加密-再发请求!sign签名校验实践需要对请求参数进行签名验证,签名方式如下:key1=value1&key2=value2&key3=value3&secret=yc 。对这个字符串进行md5一下。然后被sign后的接口就变成了:http://api.test.com/getbanner?key1=value1&key2=value2&key3=value3&sign=xxx为什么在获取sign的时候建议使用secret参数?secret仅作加密使用,添加在参数中主要是md5,为了保证数据安全请不要在请求参数中使用。服务端对sign校验这样请求的时候就需要合法正确签名sign才可以获取数据。这样就解决了身份验证和防止参数篡改问题,如果请求参数被人拿走,没事,他们永远也拿不到secret,因为secret是不传递的。再也无法伪造合法的请求。如何保证请求的唯一性http://api.test.com/getbanner?key1=value1&key2=value2&key3=value3&sign=xxx&stamp=201803261407通过stamp时间戳用来验证请求是否过期。这样就算被人拿走完整的请求链接也是无效的。Sign签名安全性分析:通过上面的案例,安全的关键在于参与签名的secret,整个过程中secret是不参与通信的,所以只要保证secret不泄露,请求就不会被伪造。05.架构设计说明5.1 整体架构设计如下所示5.2 关键流程图5.3 稳定性设计对于请求和响应的数据加解密要注意在网络上交换数据(网络请求数据)时,可能会遇到不可见字符,不同的设备对字符的处理方式有一些不同。Base64对数据内容进行编码来适合传输。准确说是把一些二进制数转成普通字符用于网络传输。统统变成可见字符,这样出错的可能性就大降低了。5.4 降级设计可以一键配置AB测试开关.setMonitorToggle(object : IMonitorToggle { override fun isOpen(): Boolean { //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关 return false } })5.5 异常设计说明base64加密和解密导致错误问题Android 有自带的Base64实现,flag要选Base64.NO_WRAP,不然末尾会有换行影响服务端解码。导致解码失败。5.6 Api文档关于初始化配置NotCaptureHelper.getInstance().config = CaptureConfig.builder() //设置debug模式 .setDebug(true) //设置是否禁用代理 .setProxy(false) //设置是否进行数据加密和解密, .setEncrypt(true) //设置cer证书路径 .setCerPath("") //设置是否进行CA证书校验 .setCaVerify(false) //设置加密和解密key .setEncryptKey(key) //设置参数 .setReservedQueryParam(OkHttpBuilder.RESERVED_QUERY_PARAM_NAMES) .setMonitorToggle(object : IMonitorToggle { override fun isOpen(): Boolean { //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关 return false } }) .build()设置okHttp配置NotCaptureHelper.getInstance().setOkHttp(app,okHttpBuilder)如何设置自己的加解密方式NotCaptureHelper.getInstance().encryptDecryptListener = object : EncryptDecryptListener { /** * 外部实现自定义加密数据 */ override fun encryptData(key: String, data: String): String { LoggerReporter.report("NotCaptureHelper", "encryptData data : $data") val str = data.encryptWithRC4(key) ?: "" LoggerReporter.report("NotCaptureHelper", "encryptData str : $str") return str } /** * 外部实现自定义解密数据 */ override fun decryptData(key: String, data: String): String { LoggerReporter.report("NotCaptureHelper", "decryptData data : $data") val str = data.decryptWithRC4(key) ?: "" LoggerReporter.report("NotCaptureHelper", "decryptData str : $str") return str } }5.7 防抓包功能自测网络请求测试正常请求,测试网络功能是否正常抓包测试配置fiddler,charles等工具手机上设置代理手机上安装证书单向认证测试:进行网络请求,会提示SSLHandshakeException即ssl握手失败的错误提示,即表示app端的单向认证成功。数据加解密:进行网络请求,看一下请求参数和响应body数据是否加密,如果看不到实际json实体则表示加密成功。防抓包库:https://github.com/yangchong211/YCToolLib综合库:https://github.com/yangchong211/YCAppTool视频播放器:https://github.com/yangchong211/YCVideoPlayer
目录介绍00.问题思考分析01.前沿简单介绍02.如何理解接口隔离原则03.接口理解为一组API接口集合04.接口理解为单个API接口或函数05.接口理解为OOP中的接口概念06.总结一下分享07.思考一道课后题00.问题思考分析01.什么叫作接口隔离法则,它和面向对象中的接口有何区别?01.前沿简单介绍学习了 SOLID 原则中的单一职责原则、开闭原则和里式替换原则,今天我们学习第四个原则,接口隔离原则。它对应 SOLID 中的英文字母“I”。对于这个原则,最关键就是理解其中“接口”的含义。那针对“接口”,不同的理解方式,对应在原则上也有不同的解读方式。除此之外,接口隔离原则跟我们之前讲到的单一职责原则还有点儿类似,所以今天我也会具体讲一下它们之间的区别和联系。02.如何理解接口隔离原则接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISP。Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。实际上,“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。前面我提到,理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:一组 API 接口集合单个 API 接口或函数OOP 中的接口概念03.接口理解为一组API接口集合还是结合一个例子来讲解。微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示:public interface UserService { boolean register(String cellphone, String password); boolean login(String cellphone, String password); UserInfo getUserInfoById(long id); UserInfo getUserInfoByCellphone(String cellphone); } public class UserServiceImpl implements UserService { //... }现在,我们的后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。这个时候我们该如何来做呢?你可能会说,这不是很简单吗,我只需要在 UserService 中新添加一个 deleteUserByCellphone() 或 deleteUserById() 接口就可以了。这个方法可以解决问题,但是也隐藏了一些安全隐患。删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,尽量避免接口被误用。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。具体的代码实现如下所示:public interface UserService { boolean register(String cellphone, String password); boolean login(String cellphone, String password); UserInfo getUserInfoById(long id); UserInfo getUserInfoByCellphone(String cellphone); } public interface RestrictedUserService { boolean deleteUserByCellphone(String cellphone); boolean deleteUserById(long id); } public class UserServiceImpl implements UserService, RestrictedUserService { // ...省略实现代码... }在刚刚的这个例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。04.接口理解为单个API接口或函数现在我们再换一种理解方式,把接口理解为单个接口或函数(以下为了方便讲解,我都简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。接下来,我们还是通过一个例子来解释一下。public class Statistics { private Long max; private Long min; private Long average; private Long sum; private Long percentile99; private Long percentile999; //...省略constructor/getter/setter等方法... } public Statistics count(Collection<Long> dataSet) { Statistics statistics = new Statistics(); //...省略计算逻辑... return statistics; }在上面的代码中,count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:public Long max(Collection<Long> dataSet) { //... } public Long min(Collection<Long> dataSet) { //... } public Long average(Colletion<Long> dataSet) { //... } // ...省略其他统计函数...不过,你可能会说,在某种意义上讲,count() 函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。如果在项目中,对每个统计需求,Statistics 定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。相反,如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,比如,有的只需要用到 max、min、average 这三类统计信息,有的只需要用到 average、sum。而 count() 函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count() 函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。05.接口理解为OOP中的接口概念还可以把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。我还是通过一个例子来给你解释。假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示。注意,这里我只给出了 RedisConfig 的代码实现,另外两个都是类似的,我这里就不贴了。public class RedisConfig { private ConfigSource configSource; //配置中心(比如zookeeper) private String address; private int timeout; private int maxTotal; //省略其他配置: maxWaitMillis,maxIdle,minIdle... public RedisConfig(ConfigSource configSource) { this.configSource = configSource; } public String getAddress() { return this.address; } //...省略其他get()、init()方法... public void update() { //从configSource加载配置到address/timeout/maxTotal... } } public class KafkaConfig { //...省略... } public class MysqlConfig { //...省略... }现在,我们有一个新的功能需求,希望支持 Redis 和 Kafka 配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)。但是,因为某些原因,我们并不希望对 MySQL 的配置信息进行热更新。为了实现这样一个功能需求,我们设计实现了一个 ScheduledUpdater 类,以固定时间频率(periodInSeconds)来调用 RedisConfig、KafkaConfig 的 update() 方法更新配置信息。具体的代码实现如下所示:public interface Updater { void update(); } public class RedisConfig implemets Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class KafkaConfig implements Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class MysqlConfig { //...省略其他属性和方法... } public class ScheduledUpdater { private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();; private long initialDelayInSeconds; private long periodInSeconds; private Updater updater; public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) { this.updater = updater; this.initialDelayInSeconds = initialDelayInSeconds; this.periodInSeconds = periodInSeconds; } public void run() { executor.scheduleAtFixedRate(new Runnable() { @Override public void run() { updater.update(); } }, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS); } } public class Application { ConfigSource configSource = new ZookeeperConfigSource(/*省略参数*/); public static final RedisConfig redisConfig = new RedisConfig(configSource); public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource); public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource); public static void main(String[] args) { ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300); redisConfigUpdater.run(); ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60); redisConfigUpdater.run(); } }刚刚的热更新的需求我们已经搞定了。现在,我们又有了一个新的监控功能需求。通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的。所以,我们希望能有一种更加方便的配置信息查看方式。为了实现这样一个功能,我们还需要对上面的代码做进一步改造。改造之后的代码如下所示:public interface Updater { void update(); } public interface Viewer { String outputInPlainText(); Map<String, String> output(); } public class RedisConfig implemets Updater, Viewer { //...省略其他属性和方法... @Override public void update() { //... } @Override public String outputInPlainText() { //... } @Override public Map<String, String> output() { //...} } public class KafkaConfig implements Updater { //...省略其他属性和方法... @Override public void update() { //... } } public class MysqlConfig implements Viewer { //...省略其他属性和方法... @Override public String outputInPlainText() { //... } @Override public Map<String, String> output() { //...} } public class SimpleHttpServer { private String host; private int port; private Map<String, List<Viewer>> viewers = new HashMap<>(); public SimpleHttpServer(String host, int port) {//...} public void addViewers(String urlDirectory, Viewer viewer) { if (!viewers.containsKey(urlDirectory)) { viewers.put(urlDirectory, new ArrayList<Viewer>()); } this.viewers.get(urlDirectory).add(viewer); } public void run() { //... } } public class Application { ConfigSource configSource = new ZookeeperConfigSource(); public static final RedisConfig redisConfig = new RedisConfig(configSource); public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource); public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource); public static void main(String[] args) { ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300); redisConfigUpdater.run(); ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60); redisConfigUpdater.run(); SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389); simpleHttpServer.addViewer("/config", redisConfig); simpleHttpServer.addViewer("/config", mysqlConfig); simpleHttpServer.run(); } }至此,热更新和监控的需求我们就都实现了。我们来回顾一下这个例子的设计思想。设计了两个功能非常单一的接口:Updater 和 Viewer。ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。你可能会说,如果我们不遵守接口隔离原则,不设计 Updater 和 Viewer 两个小接口,而是设计一个大而全的 Config 接口,让 RedisConfig、KafkaConfig、MysqlConfig 都实现这个 Config 接口,并且将原来传递给 ScheduledUpdater 的 Updater 和传递给 SimpleHttpServer 的 Viewer,都替换为 Config,那会有什么问题呢?我们先来看一下,按照这个思路来实现的代码是什么样的。public interface Config { void update(); String outputInPlainText(); Map<String, String> output(); } public class RedisConfig implements Config { //...需要实现Config的三个接口update/outputIn.../output } public class KafkaConfig implements Config { //...需要实现Config的三个接口update/outputIn.../output } public class MysqlConfig implements Config { //...需要实现Config的三个接口update/outputIn.../output } public class ScheduledUpdater { //...省略其他属性和方法.. private Config config; public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) { this.config = config; //... } //... } public class SimpleHttpServer { private String host; private int port; private Map<String, List<Config>> viewers = new HashMap<>(); public SimpleHttpServer(String host, int port) {//...} public void addViewer(String urlDirectory, Config config) { if (!viewers.containsKey(urlDirectory)) { viewers.put(urlDirectory, new ArrayList<Config>()); } viewers.get(urlDirectory).add(config); } public void run() { //... } }这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因。首先,第一种设计思路更加灵活、易扩展、易复用。因为 Updater、Viewer 职责更加单一,单一就意味了通用、复用性好。比如,我们现在又有一个新的需求,开发一个 Metrics 性能统计模块,并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上,以方便查看。这个时候,尽管 Metrics 跟 RedisConfig 等没有任何关系,但我们仍然可以让 Metrics 类实现非常通用的 Viewer 接口,复用 SimpleHttpServer 的代码实现。具体的代码如下所示:public class ApiMetrics implements Viewer {//...} public class DbMetrics implements Viewer {//...} public class Application { ConfigSource configSource = new ZookeeperConfigSource(); public static final RedisConfig redisConfig = new RedisConfig(configSource); public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource); public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource); public static final ApiMetrics apiMetrics = new ApiMetrics(); public static final DbMetrics dbMetrics = new DbMetrics(); public static void main(String[] args) { SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389); simpleHttpServer.addViewer("/config", redisConfig); simpleHttpServer.addViewer("/config", mySqlConfig); simpleHttpServer.addViewer("/metrics", apiMetrics); simpleHttpServer.addViewer("/metrics", dbMetrics); simpleHttpServer.run(); } }第二种设计思路在代码实现上做了一些无用功。因为 Config 接口中包含两类不相关的接口,一类是 update(),一类是 output() 和 outputInPlainText()。理论上,KafkaConfig 只需要实现 update() 接口,并不需要实现 output() 相关的接口。同理,MysqlConfig 只需要实现 output() 相关接口,并需要实现 update() 接口。但第二种设计思路要求 RedisConfig、KafkaConfig、MySqlConfig 必须同时实现 Config 的所有接口函数(update、output、outputInPlainText)。除此之外,如果我们要往 Config 中继续添加一个新的接口,那所有的实现类都要改动。相反,如果我们的接口粒度比较小,那涉及改动的类就比较少。06.总结一下分享1.如何理解“接口隔离原则”?理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。2.接口隔离原则与单一职责原则的区别单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。07.思考一道课后题java.util.concurrent 并发包提供了 AtomicInteger 这样一个原子类,其中有一个函数 getAndIncrement() 是这样定义的:给整数增加一,并且返回未増之前的值。我的问题是,这个函数的设计是否符合单一职责原则和接口隔离原则?为什么?/** * Atomically increments by one the current value. * @return the previous value */ public final int getAndIncrement() {//...}开源项目:https://github.com/yangchong211/YCAppTool开源博客:https://github.com/yangchong211/YCBlogs
目录介绍00.问题思考分析01.前沿简单介绍02.如何理解开闭原则03.举一个原始的例子04.修改后的代码05.修改代码违背原则么06.如何做到开闭原则07.如何运用开闭原则08.总结一下内容00.问题思考分析01.什么叫作开闭原则,他的主要用途是什么?02.如何做到拓展开放,修改封闭这一准则,结合案例说一下如何实现?01.前沿简单介绍学习 SOLID 中的第二个原则:开闭原则。个人觉得,开闭原则是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。之所以说这条原则难理解,那是因为,“怎样的代码改动才被定义为‘扩展’?怎样的代码改动才被定义为‘修改’?怎么才算满足或违反‘开闭原则’?修改代码就一定意味着违反‘开闭原则’吗?”等等这些问题,都比较难理解。之所以说这条原则难掌握,那是因为,“如何做到‘对扩展开放、修改关闭’?如何在项目中灵活地应用‘开闭原则’,以避免在追求扩展性的同时影响到代码的可读性?”等等这些问题,都比较难掌握。之所以说这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在 23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。02.如何理解开闭原则开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。这个描述比较简略,如果我们详细表述一下,那就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。03.举一个原始的例子为了让你更好地理解这个原则,我举一个例子来进一步解释一下。这是一段 API 接口监控告警的代码。其中,AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。关于 API 接口监控告警这部分,更加详细的业务需求分析和设计,我们会在后面的设计模式模块再拿出来进一步讲解,这里你只要简单知道这些,就够我们今天用了。public class Alert { private AlertRule rule; private Notification notification; public Alert(AlertRule rule, Notification notification) { this.rule = rule; this.notification = notification; } public void check(String api, long requestCount, long errorCount, long durationOfSeconds) { long tps = requestCount / durationOfSeconds; if (tps > rule.getMatchedRule(api).getMaxTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) { notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } } }上面这段代码非常简单,业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?主要的改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:public class Alert { // ...省略AlertRule/Notification属性和构造函数... // 改动一:添加参数timeoutCount public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) { long tps = requestCount / durationOfSeconds; if (tps > rule.getMatchedRule(api).getMaxTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) { notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } // 改动二:添加接口超时处理逻辑 long timeoutTps = timeoutCount / durationOfSeconds; if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } } }这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)。04.修改后的代码上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?我们先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。具体的代码实现如下所示:public class Alert { private List<AlertHandler> alertHandlers = new ArrayList<>(); public void addAlertHandler(AlertHandler alertHandler) { this.alertHandlers.add(alertHandler); } public void check(ApiStatInfo apiStatInfo) { for (AlertHandler handler : alertHandlers) { handler.check(apiStatInfo); } } } public class ApiStatInfo {//省略constructor/getter/setter方法 private String api; private long requestCount; private long errorCount; private long durationOfSeconds; } public abstract class AlertHandler { protected AlertRule rule; protected Notification notification; public AlertHandler(AlertRule rule, Notification notification) { this.rule = rule; this.notification = notification; } public abstract void check(ApiStatInfo apiStatInfo); } public class TpsAlertHandler extends AlertHandler { public TpsAlertHandler(AlertRule rule, Notification notification) { super(rule, notification); } @Override public void check(ApiStatInfo apiStatInfo) { long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds(); if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) { notification.notify(NotificationEmergencyLevel.URGENCY, "..."); } } } public class ErrorAlertHandler extends AlertHandler { public ErrorAlertHandler(AlertRule rule, Notification notification){ super(rule, notification); } @Override public void check(ApiStatInfo apiStatInfo) { if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) { notification.notify(NotificationEmergencyLevel.SEVERE, "..."); } } }上面的代码是对 Alert 的重构,我们再来看下,重构之后的 Alert 该如何使用呢?具体的使用代码我也写在这里了。其中,ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码 notification = new Notification(/*.省略参数.*/); //省略一些初始化代码 alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); } public Alert getAlert() { return alert; } // 饿汉式单例 private static final ApplicationContext instance = new ApplicationContext(); private ApplicationContext() { instance.initializeBeans(); } public static ApplicationContext getInstance() { return instance; } } public class Demo { public static void main(String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo(); // ...省略设置apiStatInfo数据值的代码 ApplicationContext.getInstance().getAlert().check(apiStatInfo); } }现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?主要的改动有下面四处。第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。第二处改动是:添加新的 TimeoutAlertHander 类。第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。改动之后的代码如下所示:public class Alert { // 代码未改动... } public class ApiStatInfo {//省略constructor/getter/setter方法 private String api; private long requestCount; private long errorCount; private long durationOfSeconds; private long timeoutCount; // 改动一:添加新字段 } public abstract class AlertHandler { //代码未改动... } public class TpsAlertHandler extends AlertHandler {//代码未改动...} public class ErrorAlertHandler extends AlertHandler {//代码未改动...} // 改动二:添加新的handler public class TimeoutAlertHandler extends AlertHandler {//省略代码...} public class ApplicationContext { private AlertRule alertRule; private Notification notification; private Alert alert; public void initializeBeans() { alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码 notification = new Notification(/*.省略参数.*/); //省略一些初始化代码 alert = new Alert(); alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); // 改动三:注册handler alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification)); } //...省略其他未改动代码... } public class Demo { public static void main(String[] args) { ApiStatInfo apiStatInfo = new ApiStatInfo(); // ...省略apiStatInfo的set字段代码 apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值 ApplicationContext.getInstance().getAlert().check(apiStatInfo); }重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。05.修改代码违背原则么看了上面重构之后的代码,你可能还会有疑问:在添加新的告警逻辑的时候,尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的,但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,那改动一、三、四不就违背了开闭原则吗?我们先来分析一下改动一:往 ApiStatInfo 类中添加新的属性 timeoutCount。实际上,我们不仅往 ApiStatInfo 类中添加了属性,还添加了对应的 getter/setter 方法。那这个问题就转化为:给类中添加新的属性和方法,算作“修改”还是“扩展”?我们再一块回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。我们再来分析一下改动三和改动四:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。为什么这么说呢?在重构之后的 Alert 代码中,我们的核心逻辑集中在 Alert 类及其各个 handler 中,当我们在添加新的告警逻辑的时候,Alert 类完全不需要修改,而只需要扩展一个新 handler 类。如果我们把 Alert 类及各个 handler 类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。06.如何做到开闭原则在刚刚的例子中,我们通过引入一组 handler 的方式来实现支持开闭原则。如果你没有太多复杂代码的设计和开发经验,你可能会有这样的疑问:这样的代码设计思路怎么想不到呢?你是怎么想到的呢?先给你个结论,之所以我能想到,靠的就是理论知识和实战经验,这些需要你慢慢学习和积累。对于如何做到“对扩展开放、修改关闭”,我们也有一些指导思想和具体的方法论,我们一块来看一下。实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。代码的扩展性是代码质量评判的最重要的标准之一。实际上,我们整个专栏的大部分知识点都是围绕扩展性问题来讲解的。专栏中讲到的很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。今天我重点讲一下,如何利用多态、依赖注入、基于接口而非实现编程,来实现“对扩展开放、对修改关闭”。实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同的角度、不同的层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”这一思想。接下来,我就通过一个例子来解释一下,如何利用这几个设计思想或原则来实现“对扩展开放、对修改关闭”。注意,依赖注入后面会讲到,如果你对这块不了解,可以暂时先忽略这个概念,只关注多态、基于接口而非实现编程以及抽象意识。比如,我们代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:// 这一部分体现了抽象意识 public interface MessageQueue { //... } public class KafkaMessageQueue implements MessageQueue { //... } public class RocketMQMessageQueue implements MessageQueue {//...} public interface MessageFormatter { //... } public class JsonMessageFormatter implements MessageFormatter {//...} public class MessageFormatter implements MessageFormatter {//...} public class Demo { private MessageQueue msgQueue; // 基于接口而非实现编程 public Demo(MessageQueue msgQueue) { // 依赖注入 this.msgQueue = msgQueue; } // msgFormatter:多态、依赖注入 public void sendNotification(Notification notification, MessageFormatter msgFormatter) { //... } }07.如何运用开闭原则如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。比如,我们之前举的 Alert 告警的例子。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。之前举的 Alert 告警的例子中,如果告警规则并不是很多、也不复杂,那 check() 函数中的 if 语句就不会很多,代码逻辑也不复杂,代码行数也不多,那最初的第一种代码实现思路简单易读,就是比较合理的选择。相反,如果告警规则很多、很复杂,check() 函数的 if 语句、代码逻辑就会很多、很复杂,相应的代码行数也会很多,可读性、可维护性就会变差,那重构之后的第二种代码实现思路就是更加合理的选择了。总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。08.总结一下内容1.如何理解“对扩展开放、对修改关闭”?添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。2.如何做到“对扩展开放、修改关闭”?我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。3.学习设计原则,要多问个为什么。不能把设计原则当真理,而是要理解设计原则背后的思想。搞清楚这个,比单纯理解原则讲的是啥,更能让你灵活应用原则。09.实践案例分析将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展?看看下面这段代码,改编某伟大公司产品代码,你觉得可以利用面向对象设计原则如何改进?public class VIPCenter { void serviceVIP(T extend User user>) { if (user instanceof SlumDogVIP) { // 穷 X VIP,活动抢的那种 // do somthing } else if(user instanceof RealVIP) { // do somthing } // ... }这段代码的一个问题是,业务逻辑集中在一起,当出现新的用户类型时,比如,大数据发现了我们是肥羊,需要去收获一下, 这就需要直接去修改服务方法代码实现,这可能会意外影响不相关的某个用户类型逻辑。利用开关原则,可以尝试改造为下面的代码。将不同对象分类的服务方法进行抽象,把业务逻辑的紧耦合关系拆开,实现代码的隔离保证了方便的扩展。技术博客大总结public class VIPCenter { private Map<User.TYPE, ServiceProvider> providers; void serviceVIP(T extend User user) { providers.get(user.getType()).service(user); } } interface ServiceProvider{ void service(T extend User user) ; } class SlumDogVIPServiceProvider implements ServiceProvider{ void service(T extend User user){ // do somthing } } class RealVIPServiceProvider implements ServiceProvider{ void service(T extend User user) { // do something } }开源项目:https://github.com/yangchong211/YCAppTool开源博客:https://github.com/yangchong211/YCBlogs
目录介绍00.问题思考分析01.前沿基础介绍02.如何理解单一指责03.如何判断是否单一04.单一判断原则05.单一就更好么06.总结回顾一下00.问题思考分析01.如何理解类的单一指责,单一指责中这个单一是如何评判的?02.懂了,但是会用么,或者实际开发中有哪些运用,能否举例说明单一职责优势?03.单一指责是否设计越单一,越好呢?说出你的缘由和论证的思路想法?04.单一职责原则,除了应用到类的设计上,还能延伸到哪些其他设计方面吗?01.前沿基础介绍学习一些经典的设计原则,其中包括,SOLID、KISS、YAGNI、DRY、LOD等。这些设计原则,从字面上理解,都不难。你一看就感觉懂了,一看就感觉掌握了,但真的用到项目中的时候,你会发现,“看懂”和“会用”是两回事,而“用好”更是难上加难。从工作经历来看,很多同事因为对这些原则理解得不够透彻,导致在使用的时候过于教条主义,拿原则当真理,生搬硬套,适得其反。02.如何理解单一指责单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single reponsibility。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。注意,这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。关于这两个概念,在专栏中,有两种理解方式。一种理解是:把模块看作比类更加抽象的概念,类也可以看作模块。另一种理解是:把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。不管哪种理解方式,单一职责原则在应用到这两个描述对象的时候,道理都是相通的。为了方便你理解,接下来我只从“类”设计的角度,来讲解如何应用这个设计原则。对于“模块”来说,你可以自行引申。单一职责原则的定义描述非常简单,也不难理解。一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。举一个例子来解释一下。比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。举一个例子来解释一下。比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。03.如何判断是否单一从刚刚这个例子来看,单一职责原则看似不难应用。那是因为我举的这个例子比较极端,一眼就能看出订单和用户毫不相干。但大部分情况下,类里的方法是归为同一类功能,还是归为不相关的两类功能,并不是那么容易判定的。在真实的软件开发中,对于一个类是否职责单一的判定,是很难拿捏的。我举一个更加贴近实际的例子来给你解释一下。在一个社交产品中,我们用下面的 UserInfo 类来记录用户的信息。你觉得,UserInfo 类的设计是否满足单一职责原则呢?public class UserInfo { private long userId; private String username; private String email; private String telephone; private long createTime; private long lastLoginTime; private String avatarUrl; private String provinceOfAddress; // 省 private String cityOfAddress; // 市 private String regionOfAddress; // 区 private String detailedAddress; // 详细地址 // ...省略其他属性和方法... }对于这个问题,有两种不同的观点。一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。我们再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了跟多其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。从刚刚这个例子,我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。除此之外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。比如,例子中的 UserInfo 类。如果我们从“用户”这个业务层面来看,UserInfo 包含的信息都属于用户,满足职责单一原则。如果我们从更加细分的“用户展示信息”“地址信息”“登录认证信息”等等这些更细粒度的业务层面来看,那 UserInfo 就应该继续拆分。综上所述,评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构(后面的章节中我们会讲到)。04.单一判断原则听到这里,你可能会说,这个原则如此含糊不清、模棱两可,到底该如何拿捏才好啊?我这里还有一些小技巧,能够很好地帮你,从侧面上判定一个类的职责是否够单一。而且,我个人觉得,下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。不过,你可能还会有这样的疑问:在上面的判定原则中,我提到类中的代码行数、函数或者属性过多,就有可能不满足单一职责原则。那多少行代码才算是行数过多呢?多少个函数、属性才称得上过多呢?比较初级的工程师经常会问这类问题。实际上,这个问题并不好定量地回答,就像你问大厨“放盐少许”中的“少许”是多少,大厨也很难告诉你一个特别具体的量值。实际上, 从另一个角度来看,当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了。实际上,等你做多项目了,代码写多了,在开发中慢慢“品尝”,自然就知道什么是“放盐少许”了,这就是所谓的“专业第六感”。05.单一就更好么为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。我们还是通过一个例子来解释一下。Serialization 类实现了一个简单协议的序列化和反序列功能,具体代码如下:/** * Protocol format: identifier-string;{gson string} * For example: UEUEUE;{"a":"A","b":"B"} */ public class Serialization { private static final String IDENTIFIER_STRING = "UEUEUE;"; private Gson gson; public Serialization() { this.gson = new Gson(); } public String serialize(Map<String, String> object) { StringBuilder textBuilder = new StringBuilder(); textBuilder.append(IDENTIFIER_STRING); textBuilder.append(gson.toJson(object)); return textBuilder.toString(); } public Map<String, String> deserialize(String text) { if (!text.startsWith(IDENTIFIER_STRING)) { return Collections.emptyMap(); } String gsonStr = text.substring(IDENTIFIER_STRING.length()); return gson.fromJson(gsonStr, Map.class); } }如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。拆分后的具体代码如下所示:public class Serializer { private static final String IDENTIFIER_STRING = "UEUEUE;"; private Gson gson; public Serializer() { this.gson = new Gson(); } public String serialize(Map<String, String> object) { StringBuilder textBuilder = new StringBuilder(); textBuilder.append(IDENTIFIER_STRING); textBuilder.append(gson.toJson(object)); return textBuilder.toString(); } } public class Deserializer { private static final String IDENTIFIER_STRING = "UEUEUE;"; private Gson gson; public Deserializer() { this.gson = new Gson(); } public Map<String, String> deserialize(String text) { if (!text.startsWith(IDENTIFIER_STRING)) { return Collections.emptyMap(); } String gsonStr = text.substring(IDENTIFIER_STRING.length()); return gson.fromJson(gsonStr, Map.class); } }虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。06.总结回顾一下1.如何理解单一职责原则(SRP)?一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。2.如何判断类的职责是否足够单一?不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:类中的代码行数、函数或者属性过多;类依赖的其他类过多,或者依赖类的其他类过多;私有方法过多;比较难给类起一个合适的名字;类中大量的方法都是集中操作类中的某几个属性。3.类的职责是否设计得越单一越好?单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。开源项目:https://github.com/yangchong211/YCAppTool开源博客:https://github.com/yangchong211/YCBlogs
目录介绍01.整体概述说明1.1 重构的背景1.2 重构的要求1.3 遇到问题1.4 重构的目的1.5 设计目标1.6 产生收益分析02.重构的具体实践2.1 重构什么2.2 何时重构2.3 思考如何重构2.4 针对复杂场景03.重构技术手段3.0 举一个重构例子3.1 罗列重构事项3.2 把握关键节点3.3 编写测试用例3.4 mock业务数据3.5 发现代码bug3.6 优化编码方式04.避免重构失败4.1 能否给充足理由4.2 乱套设计模式4.3 先有问题后改造05.架构设计思考5.1 针对复杂场景5.2 如何做架构设计01.整体概述说明1.1 重构的背景项目的代码往往牵一发而动全身,业务逻辑耦合严重。对于大的架构重构,其实一直很谨慎的。原则是将重构融合在每次迭代中,逐步优化代码的结构。然后将这个工作持续进行下去!当初设计的架构让项目的依赖关系越来越复杂,维护成本也越来越高。决定梳理并优化一下整个项目结构。在实施过程中,依然坚持将整个重构的过程融合在每个迭代中,逐步完成一次大的架构升级。1.2 重构的要求重构代码对一个工程师能力的要求,要比单纯写代码高得多重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。提高代码的质量具体点说就是,提高代码的可读性、可扩展性、可维护性等。多问自己为什么这样设计在做代码设计的时候,一定要先问下自己,为什么要这样设计,这样做是否能真正地提高代码质量,能提高代码质量的哪些方面。如果自己很难讲清楚,或者给出的理由都比较牵强,没有压倒性的优势,那基本上就可以断定这是一种过度设计,是为了设计而设计。1.3 遇到问题项目痛点在哪里先要去分析代码存在的痛点,比如可读性不好、可扩展性不好等等,然后再针对性地利用设计模式去改善。对重构理解不深入对为什么要重构、到底重构什么、什么时候重构、又该如何重构等相关问题理解不深,对重构没有系统性、全局性的认识。对重构没有技巧面对一堆烂代码,没有重构技巧的指导,只能想到哪改到哪,并不能全面地改善代码质量。1.4 重构的目的软件设计大师 Martin Fowler 是这样定义重构重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。重构的定义很重要有一个值得强调的点:“重构不改变外部的可见行为”。把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。遇到问题再重构维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。1.5 设计目标重构围绕一个老生常谈的概念「解耦」「拓展」「维护」等维度展开,设定几个目标:清晰划分各模块的角色明确架构层级及各个模块所在的层级提高整个架构横向扩展的能力各模块独立开发,面向接口和协议编程提高代码可维护性和可读性02.重构的具体实践2.1 重构什么根据重构的规模可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)。大型重构指的是对顶层代码设计的重构包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是使用学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。小型重构指的是对代码细节的重构主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用我们能后面要讲到的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。你只需要熟练掌握各种编码规范,就可以做到得心应手。2.2 何时重构需要说明的问题个人比较反对,平时不注重代码质量,堆砌烂代码,实在维护不了了就大刀阔斧地重构、甚至重写的行为。有时候项目代码太多了,重构很难做得彻底,这就更麻烦了!所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的,我们必须探索一条可持续、可演进的方式。找到代码中的问题可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,你也可以顺手把不符合编码规范、不好的设计重构一下。2.3 思考如何重构进行大型重构的时候要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。小规模低层次的重构因为影响范围小,改动耗时短,所以,随时都可以去做。按照分,拆的思想不断优化代码。借助工具分析代码问题重构除了人工去发现低层次的质量问题,还可以借助很多成熟的静态代码分析工具(比如FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化。03.重构技术手段如何保证重构不出错呢?你需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变。3.0 举一个重构例子举一个简单的例子看重构事项现状:App业务线各种依赖庞大,然后交错在一起,关系变的复杂。依赖库之间的强依赖导致版本冲突多。模块方案发生变化,上层修改成本大。功能模块兼容性导致维护成本大。重构方案:整个架构的核心思想是面向接口编程和依赖注入使各个模块之间实现解耦,然后通过横向角色划分与纵向层级划分的方式约定各个模块之间的关系,再通过接口分层的方式,明确具体模块在不同层级上需要实现的功能3.1 罗列重构事项针对重构的业务详细罗列针对一个比较大的重构业务,先进行梳理,然后根据问题或痛点思考,罗列解决方案,然后开始实践并保证代码稳定性,最后测试并交付。罗列重构事项第一步:面向接口编程,根据业务抽取抽象接口,由于接口是对某个功能需求抽象,所以不会对具体的实现形成依赖。第二步:层级划分与角色划分,总体分为三个层次:底层、组件层和应用层。第三步:除了通过接口实现模块间的通讯方式,我们还设计了一套内部通讯协议,用于在应用内部消息通讯。对于一些易变的、灵活的、简单的通讯,可以直接通过发送消息的方式进行通讯。第四步:抽离功能模块,抽离公共视图模块,抽离公共业务模块,抽离产线业务模块。3.2 把握关键节点比如针对该例子关键点第一个关键点:依赖错综复杂,那么就需要划分层次图,将App的架构设计分层。比如基础层,组件层,服务层,业务层,壳工程。然后定义好从上到下的关系,这样依赖就清晰明了!第二个关键点:上层修改成本高,那这个时候能够根据业务抽象一套交互的接口。第三个关键点:代码的结构层次情绪,视图层,数据处理层,业务层等层次清晰。低耦合!3.3 编写测试用例编写详细的测试用例可以模仿谷歌官方demo来编写测试用例,这个测试用例的测试粒度越小越好。首先保证主流程畅通,然后在写边界测试用例。要把持续重构作为开发的一部分来执行那写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。3.4 mock业务数据mock业务数据很重要尤其是有接口请求的业务逻辑,这个时候可以mock本地json数据,然后模拟各种业务场景。十分有利于调试各种case3.5 发现代码bug积极发现代码bug很重要在代码演进中,bug避免重复出现很重要。作为实践写代码,这个过程自测case罗列一下,然后自测,自测一项勾选一项。3.6 优化编码方式了解敌人——丑陋的代码臃肿的类:开发者不去思考这些功能是不是应该放在这同一个类中,导致这些类会变得很臃肿,造成一个类几千行,让下一个接盘侠欲哭无泪。臃肿的方法:好几十上百行的一个函数堆在一块,用面向过程的思想来写代码,建议一个方法代码行数不超过80。函数参数过多:函数参数过多会导致调用者对方法难以理解,参数弄混。建议可以将参数组成一个对象传入。层层嵌套的判断:如果逻辑不复杂尽量减少if-else的分支包裹,太难阅读。比如不满足条件了直接return,不走其他代码,这样可以减少一层嵌套。满篇跑常量值:一个类里面出现各种未命名的常量值。0,1,2等等铺天盖地。这种状态码意义改了,改代码会把你改哭的。难道就不能先声明一个统一的常量变量来使用吗。模棱两可的命名:不能根据名字一眼看懂它的功能的命名不是一个好命名。当然生僻的单词除外。模糊的,没有功能意义的命名会给阅读造成很大困难。优化编码的具体操作分拆大函数:当函数比较大了,就可以根据功能节点分拆成多个小函数,也许其中的小函数还可以公用。比如结算购物车,包括计算各类商品的总价,再计算折扣,再计算满减优惠。分别拆分成三个,一眼就能看出这段逻辑先后做了什么。封装到父类:如果多各类要执行相似的功能和代码,可以把该方法放到它们的父类中,或者提取出来成业务工具类。针对类臃肿:方法迁移,遵守“单一职责”原则,当类中的方法不适合放在当前类中时,就应该为该方法寻找合适下家。移到与方法耦合大的类中。搬移字段:当在一个类中的某一个字段,被另一个类的对象频繁使用时,我们就应该考虑将这个字段的位置进行更改了提炼类:一个类如果过于复杂,做了好多的事情,违背了“单一职责”的原则,所以需要将其可以独立的模块进行拆分,当然有可能由一个类拆分出多个类。对类细化。提升方法、字段:将方法向继承链上层迁移的过程。用于一个方法被多个实现者使用时。在继承的体系中,当多个类使用了相同或类似的方法,就可以考虑将该方法抽取到基类,没有基类就创建一个。字段提升同方法。降低方法:即父类抽象方法让多个子类实现。多个子类有相同的功能但是有各个具体的实现方法,那么这种封装就可以用多态性了,父类创建一个抽象方法,将方法实现降低到子类。重复代码的提炼:有时候为了赶项目进度,尽快完成功能,会偷懒将实现功能的一片代码复制一遍,直接套用。这种把多余的删掉,保留一个,也许只需传一两个参数就可以封成一个方法供多处调用。重命名变量(类、方法、变量):这个很重要,可以不夸张地说,命名的水平就体现了编程能力的高低。在重构的过程中,当发现类名,方法名在当前版本不符合它的功能含义,就该考虑对其重新命名。补加注释:对于全局变量,公用函数,逻辑复杂的地方添加注释,弥补之前的遗漏。04.避免重构失败4.1 能否给充足理由为何要这样重构如果自己很难讲清楚,或者给出的理由都比较牵强,没有压倒性的优势,那基本上就可以断定这是一种过度设计,是为了设计而设计。4.2 乱套设计模式不假思索地套用学过的设计模式看到某个场景之后,觉得跟之前在某本书中看到的某个设计模式的应用场景很相似,就套用上去,也不考虑到底合不合适。最后如果有人问起了,就再找几个不痛不痒、很不具体的伪需求来搪塞,比如提高了代码的扩展性、满足了开闭原则等等。4.3 先有问题后改造先遇到问题然后再改造从问题讲起,一步一步给你展示为什么要用某个设计模式,而不是一开始就告诉你最终的设计。看到某段代码之后能分析就能够自己分析得头头是道,说出它好的地方、不好的地方,为什么好、为什么不好,不好的如何改善,可以应用哪种设计模式,应用了之后有哪些副作用要控制等等。05.架构设计思考5.1 针对复杂场景设计模式要干的事情就是解耦也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类,让其满足高内聚低耦合等特性。创建型模式是将创建和使用代码解耦,结构型模式是将不同的功能代码解耦,行为型模式是将不同的行为代码解耦。而解耦的主要目的是应对代码的复杂性。设计模式就是为了解决复杂代码问题而产生的。根据是否复杂投入时间对于复杂代码,比如项目代码量多、开发周期长、参与开发的人员多,我们前期要多花点时间在设计上,越是复杂代码,花在设计上的时间就要越多。如果你参与的只是一个简单的项目,代码量不多,开发人员也不多,那简单的问题用简单的解决方案就好,不要引入过于复杂的设计模式,将简单问题复杂化。5.2 如何做架构设计架构设计要以实用为目的不要光想着造一个世上最牛逼的架构,这样往往是不靠谱的,我们不是救世主。一切都是当前实际情况为主!总结下,架构设计有三个基本原则:1、合适优于世界领先。适合自己当前业务的就好,不要总想搞领先的架构,比如一个用户量100万的App,光想着对标微信的架构,适合微信的架构未必适合自己。2、简单优于复杂。如同写代码一样,代码量越少越简单越好,架构设计也是一样,越简单的架构越容易看懂和维护。3、演进优于一步到位。可扩展性我们当然要考虑,但是人不是神,无论你怎么去预测未来的系统演进,总是很大可能会失算。所以架构设计优先解决当下的问题,至于后来的问题,到时候再对架构方案进行改进。这三个原则也是有优先级的具体是:合适优于先进 > 演化优于一步到位 > 简单优于复杂合适也就是适应当前需要是首位的,连当前需求都满足不了谈不到其他。架构整体发展是要不断演进的,在这个大前提下,尽量追求简单,但也有该复杂的时候,就要复杂,比如生物从单细胞一直演化到如今,复杂是避免不了的。开源项目:https://github.com/yangchong211/YCAppTool开源博客:https://github.com/yangchong211/YCBlogs
目录介绍01.整体概述1.1 项目背景1.2 遇到问题1.3 基础概念1.4 设计目标1.5 收益分析02.Window概念2.1 Window添加View2.2 Window的概念2.3 LayoutParams2.4 WMS流程梳理03.悬浮窗技术要点3.1 业务思考点分析3.2 关键技术要点3.3 应用悬浮窗3.4 添加浮窗源码流程3.5 理解WMS原理3.6 拖拽回弹吸附04.开发重要步骤4.1 悬浮窗实现流程4.2 请求悬浮窗权限4.3 初始化悬浮窗4.4 设置悬浮窗参数4.5 添加View到悬浮窗4.6 悬浮窗拖拽实现4.8 悬浮窗权限适配4.9 LayoutParam坑05.方案基础设计5.1 整体架构图5.2 UML设计图5.3 关键流程图5.4 接口设计图5.5 模块间依赖关系06.其他设计说明6.1 性能设计6.2 稳定性设计6.3 异常设计6.4 事件上报设计07.遇到的问题和坑7.1 处理输入法层级关系7.2 边界逻辑关闭悬浮窗7.3 点击多次打开页面7.4 Home键遇到的问题01.整体概述1.1 项目背景业务场景分析以视频通话为例,在视频通话时,我们打开其他应用或点击Home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。市面上常见的悬浮窗,如微信视频通话功能,有如下特点:整屏页面能切换到一个小的悬浮窗;悬浮窗能运行在其他app上方;悬浮窗能跳回整屏页面,并且悬浮窗消失需求悬浮窗效果点击缩小按钮,将当前远端视屏加载进悬浮窗,且悬浮窗可拖拽,不影响其他界面焦点;点击悬浮窗可返回原来的Activity1.2 遇到问题什么是悬浮窗全局悬浮窗在许多应用中都能见到,点击Home键,小窗口仍然会在屏幕上显示。注意:悬浮窗注意申请权限!那么开发全局悬浮窗属于那一类呢?属于系统窗口,相当于跟Toast是一个级别的。针对悬浮窗的展示和移除,则可以模仿Toast中addView和removeView操作……视频通话Activity如何最小化Activity本身自带了一个moveTaskToBack(boolean nonRoot),我们要实现最小化只需要调用moveTaskToBack(true)传入一个true值就可以了,但是这里有一个前提,就是需要设置Activity的启动模式为singleInstance模式,两步搞定。注:activity最小化后重新从后台回到前台会回调onRestart()方法。点击悬浮窗开启activity会回调onNewIntent(注意可以setIntent(intent)一下)1.3 基础概念Window 有三种类型,分别是应用 Window、子 Window 和系统 Window。应用Window:z-index在1~99之间,它往往对应着一个Activity。子Window:z-index在1000~1999之间,它往往不能独立存在,需要依附在父Window上,例如Dialog等。系统Window:z-index在2000~2999之间,它往往需要声明权限才能创建,例如Toast、状态栏、系统音量条、错误提示框都是系统Window。这些层级范围对应着 WindowManager.LayoutParams 的 type 参数如果想要 Window 位于所有 Window 的最顶层,那么采用较大的层级即可,很显然系统 Window 的层级是最大的。Android显示系统分为3层UI框架层:负责管理窗口中View组件的布局与绘制以及响应用户输入事件WindowManagerService层:负责管理窗口Surface的布局与次序SurfaceFlinger层:将WindowManagerService管理的窗口按照一定的次序显示在屏幕上WMS(WindowManagerService)相关概念Window:它是一个抽象类,具体实现类为 PhoneWindow ,它对 View 进行管理。Window是View的容器,View是Window的具体表现内容;WindowManager:是一个接口类,继承自接口 ViewManager ,从它的名称就知道它是用来管理 Window 的,它的实现类为 WindowManagerImpl;WMS:是窗口的管理者,它负责窗口的启动、添加和删除。另外窗口的大小和层级也是由它进行管理的;1.4 设计目标目前开发悬浮窗的方案有以下几种第一种:写在base里面或者监听所有activity生命周期,这样每次启动一个新的Activity都要往页面上addView一次,耦合性比较强。第二种:采用在Window上添加View的形式,相当于是全局性的悬浮窗。封装成库,暴露Api给开发者调用。第三种:采用服务Service,然后在Service中采用WindowManager添加和移除View操作。那么在Activity中想要展示弹窗则需要通过广播通信,让Service收到广播处理逻辑。移植性比较弱!悬浮窗设计目标良好的接口设计,可以设置各种自定义视图,支持拖动和拖拽吸附到边缘。强大的Api方法和傻瓜式调用链路。展示悬浮窗能否想Popup那样依附在某控件位置我在写悬浮窗库时,思考能否想Popup那种有showAsDropDown方法Api,可以显示在某个View的重心位置,然后在设置x和y偏移量。这个是可以做到的,加上这个Api方便库的强大使用!1.5 收益分析悬浮窗收益提高产品的用户体验,app推到后台,或者推出页面做其他操作(比如查看信息),这个时候浮窗功能主要是增加通话的友好技能收益下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。使用场景有:音视频,直播,debug悬浮工具等……悬浮窗库代码https://github.com/yangchong211/YCAppTool/tree/master/WidgetLib/FloatWindow02.Window概念2.1 Window添加View先看一个简单的案例。在主屏幕上添加一个TextView并展示,并且这个TextView独占一个窗口。TextView mview = new TextView(context); WindowManager mWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); WindowManager.LayoutParams wmParams = new WindowManager.LayoutParams(); wmParams.type = WindowManager.LayoutParams.TYPE_TOAST; wmParams.width = 800; wmParams.height = 800; mWindowManager.addView(mview, wmParams);对Window添加View的流程步骤分析WindowManager.addView添加窗口之前,TextView的onDraw不会被调用,也就说View必须被添加到窗口中,才会被绘制。只有申请了依附窗口,View才会有可以绘制的目标内存。当APP通过WindowManagerService的代理向其添加窗口的时候,WindowManagerService除了自己进行登记整理,还需要向SurfaceFlinger服务申请一块Surface画布,其实主要是画布背后所对应的一块内存,只有这一块内存申请成功之后,APP端才有绘图的目标,并且这块内存是APP端同SurfaceFlinger服务端共享的,这就省去了绘图资源的拷贝。APP端是可以通过unLockCanvasAndPost直接同SurfaceFlinger通信进行重绘的,就是说图形的绘制同WMS没有关系,WMS只是负责窗口的管理,并不负责窗口的绘制。2.2 Window的概念Window是个抽象类,PhoneWindow是Window唯一的实现类。PhoneWindow像是一个工具箱,封装了三种工具:DecorView、WindowManager.LayoutParams、WindowManager。其中DecorView和WindowManager.LayoutParams负责窗口的静态属性,比如窗口的标题、背景、输入法模式、屏幕方向等等。WindowManager负责窗口的动态操作,比如窗口的增、删、改。Window抽象类对WindowManager.LayoutParams相关的属性(如:输入法模式、屏幕方向)都提供了具体的方法。而对DecorView相关的属性(如:标题、背景),只提供了抽象方法,这些抽象方法由PhoneWindow实现。Window并不是真实地存在着的,而是以View的形式存在。Window本身就只是一个抽象的概念,而View是Window的表现形式。要想显示窗口,就必须调用WindowManager.addView(View view, ViewGroup.LayoutParams params)。参数view就代表着一个窗口。在Activity和Dialog的显示过程中都会调用到wm.addView(decor, l);所以Activity和Dialog的DecorView就代表着各自的窗口。2.3 WindowManager在了解WindowManager管理View实现之前,先了解下WindowManager相关类图以及Activity界面各层级显示关系;2.4 LayoutParamsWindowManager.LayoutParams这个类用于提供悬浮窗所需的参数,其中有几个经常会用到的变量:type值用于确定悬浮窗的类型,一般设为2002,表示在所有应用程序之上,但在状态栏之下。flags值用于确定悬浮窗的行为,比如说不可聚焦,非模态对话框等等,属性非常多,大家可以查看文档。gravity值用于确定悬浮窗的对齐方式,一般设为左上角对齐,这样当拖动悬浮窗的时候方便计算坐标。x值用于确定悬浮窗的位置,如果要横向移动悬浮窗,就需要改变这个值。y值用于确定悬浮窗的位置,如果要纵向移动悬浮窗,就需要改变这个值。width值用于指定悬浮窗的宽度。height值用于指定悬浮窗的高度。那么这个里面如何计算悬浮窗上下左右的位置呢?比如有个场景悬浮窗和音视频页面放大和缩小就需要拿到悬浮窗位置普通View如何拿到上下左右位置,可以采用sourceView.getGlobalVisibleRect(visibleRect),简单来说就是对目标view在父view映射,然后从屏幕左上角开始计算,然后保存到rect中。悬浮窗View如何拿到上下左右位置,left = layoutParams.x;top = y,right = x + layoutParams.width;bottom = y + layoutParams.height03.悬浮窗技术要点3.1 业务思考点分析针对窗口缩小或者悬浮窗需要考虑几个重要的点:悬浮窗体的比例以及层级,层级要在statusBar之下且在activity之上,这样才能保证其不会被其他业务界面覆盖;悬浮框显示后,内部的内容如何无缝衔接继续显示;3.2 关键技术要点悬浮窗权限判断这个需要注意针对不同的版本需要适配权限。注意网上说有什么方法可以绕过权限申请,这个是不可能的事情。同时要注意,部分手机判断悬浮窗权限Api可能失效……将view添加到悬浮窗上利用addView将View添加在window上,同样的,WindowManager.LayoutParams.type可以设置View的层级,防止被其他业务界面所覆盖。3.3 应用悬浮窗应用内悬浮窗实现流程1.获取WindowManager;2.创建悬浮View;3.设置悬浮View的拖拽事件;4.添加View到WindowManager中对于应用悬浮窗来说,Android版本对其影响不大。Type为TYPE_APPLICATION:只要Activity建立了,就可以添加。Type为TYPE_APPLICATION_ATTACHED_DIALOG:需要在Activity获取焦点,并且用户可操作时才可添加。3.4 添加浮窗源码流程悬浮窗添加流程:-> WindowManager.addView 这个是调用ViewManager接口的addView方法添加视图-> WindowManagerImpl.addView 接着会调用具体实现类-> WindowManagerGlobal.addView 在这个方法中会找到核心的ViewRootImpl,这个Impl相当于是root根-> ViewRootImpl.setView 最后会调用setView将view设置出来,mWindowSession在创建ViewRootImpl对象时被实例化-> WindowSession.addToDisplay(AIDL进行IPC)-> WindowManagerService.addWindow()-> ViewRootImpl.setView从 WindowManager 到 WMS 的具体流程如下所示:这里讲解一下AIDL交互的流程逻辑主要分析是ViewRootImpl#setView()到WindowManagerService.addWindow()的这个过程,涉及到跨进程通信。1.ViewRootImpl#setView()过程。mWindowSession是IWindowSession对象。在创建ViewRootImpl对象时被实例化。2.WindowManagerGlobal#getWindowSession()过程。getWindowManagerService()通过AIDL返回WindowManagerService实例。之后调用WindowManagerService#openSession()。3.WindowManagerService#openSession()过程。返回一个Session对象。也就是说在ViewRootImpl#setView()中调用的是mWindowSession.addToDisplay,其实就是Session#addToDisplay()。4.Session#addToDisplay()过程。mService是个WindowManagerService对象,也就是说最后调用的是WindowManagerService#addWindow()5.WindowManagerService#addWindow()过程。mWindowMap是个Map实例,将WindowManager添加进WindowManagerService统一管理。至此,整个添加视图操作解析完毕。WindowManager.updateViewLayout()解析和addView()过程一样,最终会进入到WindowManagerGlobal#updateViewLayout()。将传入的View设置参数之后,更新mRoot中View的参数。WindowManager.removeView()解析和上面过程一样,最终会进入到WindowManagerGlobal#removeView()。这个过程要稍微麻烦点,首先调用root.die(),接着将View添加进mDyingViews。ViewRootImpl#die()中,参数immediate默认为false,也就是说这里只是发送了一个what=MSG_DIE的空消息。ViewRootHandler收到这条消息会执行doDie()。经过一圈效验最终还是回到WindowManagerGlobal中移除View3.6 拖拽回弹吸附先看微信效果当你拖动微信悬浮窗的时候,手指松开,这个时候悬浮窗回到边缘,会有一个很友好的动画过渡效果。而并非是改变位置那么生硬。为何做该功能拖拽回到边缘,如果是直接调用updateLocation,那太生硬了。如何做友好动画这里可以添加属性动画,给动画设置时间,然后在动画执行获取坐标值。然后再更改位置,这样就比较连贯,效果更好一些。04.开发重要步骤4.1 悬浮窗实现流程应用内悬浮窗实现流程第一个是获取WindowManager,然后设置相关params参数。注意配置参数的时候需要注意type第二个是添加xml或者自定义view到windowManager上第三个是处理拖拽更改view位置的监听逻辑,分别在down,move,up三个事件处理业务第四个是吸附左边或者右边,大概的思路是判断手指抬起时候的点是在屏幕左边还是右边4.2 请求悬浮窗权限关于悬浮窗的权限当API<18时,系统默认是有悬浮窗的权限,不需要去处理;当API >= 23时,需要在AndroidManifest中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在;Settings.canDrawOverlays(this)当API > 25时,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />4.3 初始化悬浮窗第一步:首先创建WindowManager//创建WindowManager windowManager = (WindowManager)applicationContext.getSystemService(Context.WINDOW_SERVICE); layoutParams = new WindowManager.LayoutParams();4.4 设置悬浮窗参数第一步:创建LayoutParamslayoutParams = new WindowManager.LayoutParams();第二步:LayoutParam设置wmParams.type = WindowManager.LayoutParams.TYPE_TOAST; wmParams.width = 800; wmParams.height = 800; mWindowManager.addView(mview, wmParams);4.5 添加View到悬浮窗界面触发悬浮窗代码如下:// 新建悬浮窗控件 View view = LayoutInflater.from(this).inflate(R.layout.float_window, null); view.setOnTouchListener(new FloatingOnTouchListener()); // 将悬浮窗控件添加到WindowManager windowManager.addView(view, layoutParams);需要注意的是,在隐藏悬浮窗的时候,最好是移除一下,下次需要显示的时候再添加。4.6 悬浮窗拖拽实现如何实现悬浮窗可随手指拖动?思路非常简单,监听悬浮窗那个onTouchListener即可,在刚点击的ACTION_DOWN(手指按下)事件中记录当前的x,y位置,然后在每次移动(ACTION_MOVE事件)后获取到本次移动的位置,二者相减就是需要移动的位置,这是自定义view的最基本操作了。如何实现悬浮窗左右边的吸顶效果?监听到手指抬起(UP事件)的动作后,判断当前位置是靠近左边还是右边,靠近左边就以位置动画的方式平移到左边,靠近右边就平移到右边。4.8 悬浮窗权限适配权限配置和请求,这一块倒是没什么坑在当Android7.0以上的时候,需要在AndroidManifest.xml文件中声明SYSTEM_ALERT_WINDOW权限<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />4.9 LayoutParam坑LayoutParam的坑!!!!WindowManager的addView方法有两个参数,一个是需要加入的控件对象View,另一个参数是WindowManager.LayoutParam对象。LayoutParam里的type变量。需要注意一个坑!!!!!!这个变量是用来指定窗口类型的。在设置这个变量时,需要对不同版本的Android系统进行适配。if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE; }在Android 8.0之前,悬浮窗口设置可以为TYPE_PHONE,这种类型是用于提供用户交互操作的非应用窗口。而Android 8.0对系统和API行为做了修改,包括使用SYSTEM_ALERT_WINDOW权限的应用无法再使用一下窗口类型来在其他应用和窗口上方显示提醒窗口:如果需要实现在其他应用和窗口上方显示提醒窗口,那么必须该为TYPE_APPLICATION_OVERLAY的新类型。如果在Android 8.0以上版本仍然使用TYPE_PHONE类型的悬浮窗口,则会出现如下异常信息:android.view.WindowManager$BadTokenException: Unable to add window android.view.ViewRootImpl$W@f8ec928 -- permission denied for window type 200205.方案基础设计5.1 整体架构图5.2 UML设计图悬浮窗整体UML类图06.其他设计说明6.1 性能设计性能设计在该库中主要涉及两点第一个如果是用在activity中,那么则需要注意内存泄漏的问题,需要释放activity上下文的引用第二个如果是用在全局,那么需要注意添加view避免重复添加(如果已经添加则首先要移除),然后销毁的时候把FloatWindow各种属性设置成null清理6.2 稳定性设计如何避免窗口移动,移动后松手的瞬间触发了点击事件首先设置一个布尔标记值(触摸移动标记),在手指按下去(ACTION_DOWN)的时候设置为false。然后在移动(ACTION_MOVE)的时候,如果用户移动了手指,那么就拦截本次触摸事件,从而不让点击事件生效。最后在手指抬起(ACTION_UP,ACTION_CANCEL)的时候,返回记录的触摸移动标记。如果是true表示自己消费事件,则不会让点击事件生效。这个地方需要注意两点第一点:为何要监听ACTION_CANCEL事件,是因为手指拖动,快速拖动到窗口外,这个时候没有手指抬起操作,那么监听事件结束主要是增强边界逻辑。第二点:怎么判断滑动?因为点击click也会执行down,move,up等一连串事件。这个时候就要判断最小move距离是否大于系统最小触摸距离,如果是则为拖动,否则是点击!如何解决滑出指定距离又滑入当作是点击事件bug这个这个,可以当作一种增强逻辑,但是但是手指操作不出来,先放着……6.3 异常设计针对悬浮窗的添加,移除和更新操作需要增加catch操作。那么为何要这样操作,模仿吐司。如下所示:try { mWindowManager.addView(mDecorView, mWindowParams); } catch (NullPointerException | IllegalStateException | IllegalArgumentException | WindowManager.BadTokenException e) { // 如果这个 View 对象被重复添加到 WindowManager 则会抛出异常 // java.lang.IllegalStateException: View has already been added to the window manager. } //下面这个是更新view try { mWindowManager.updateViewLayout(mDecorView, mWindowParams); } catch (IllegalArgumentException e) { // 当 WindowManager 已经消失时调用会发生崩溃 // IllegalArgumentException: View not attached to window manager }参考系统级别的Toast,其实悬浮窗跟吐司一样,设置系统层级后,对addView增加catch操作。try { mWM.addView(mView, mParams); } catch (WindowManager.BadTokenException e) { /* ignore */ }6.4 事件上报设计在悬浮窗中,有一部分代码添加上了catch操作。那么能否把这一部分的异常当作事件上报到APM上来第一种方案:依赖APM,然后调用api进行事件上报,显然这种是不可行的。因为该功能库是不想依赖太大的外部库。第二种方案:采用接口+实现类,通过反射的形式去调用。但这样又感觉不太好,采用Class.forName要避免混淆导致类找不到。第三种方案:采用抽象类+实现类,将实现类的对象设置到抽象类中调用,实现类在壳工程做具体操作。具体实现步骤如下所示举一个简单的例子说明该思路,比如,我在悬浮窗依赖接口层,然后调用代码如下所示ExceptionReporter.reportCrash("Float FloatWindow updateViewLayout", e);然后,在app壳工程中具体操作如下所示ExceptionReporter.setExceptionReporter(ExceptionHelperImpl()) public class ExceptionReporterImpl extends ExceptionReporter { @Override protected void reportCrash(Throwable throwable) { //壳工程中可以拿到APM,比如上传到bugly平台上 } @Override protected void reportCrash(String tag, Throwable throwable) { } }07.遇到的问题和坑7.1 处理输入法层级关系先看一下问题微信里的悬浮窗是在输入法之下的,所以交互的同学也要求悬浮窗也要在输入法之下。查看了一下WindowManager源码,悬浮窗的优先级TYPE_APPLICATION_OVERLAY,上面大字写着明明是在输入法之下的,但是实际表现是在输入法之上。7.2 边界逻辑关闭悬浮窗先看一下问题谷歌坑人的地方,都没地方设置这个悬浮窗是否只用到app内,所以默认在桌面上也会显示自己的悬浮窗。比如在微信里显示其他app的悬浮窗,这种糟糕的体验可想而知,用户不给你卸载就真是奇迹了。尝试解决这个问题为了解决这个问题,最初的实现方式是对所有经过的activity进行记录,显示就加1,页面被挂起就减1,如果减到当前计数为0时说明所有页面已经关闭了,就可以隐藏悬浮窗了。实际上这么做还是有问题的,在部分手机上如果是在首页按返回键的话仍然不能隐藏,这个又是系统级的兼容性问题。为了解决这问题,后面又做了一个处理,通过注册registerActivityLifecycleCallbacks监听app的前后台回调,检测到如果当前首页被销毁时,应该将悬浮窗进行隐藏。7.3 点击多次打开页面问题说明一下如果你的悬浮窗点击事件是打开页面的话,这里需要注意了,别忘了将这个打开的页面的启动模式设置为singleTop或者是singleTask,从而复用同一个,远离一直按返回的地狱操作。7.4 Home键遇到的问题先说一下遇到问题的场景按home退到桌面从桌面点击应用图标又从启动页重新启动的,挺奇怪的。点击home键按道理说是不会推出MainActivity的呀先说下代码逻辑语音/视频通话界面activity 配置 android:launchMode=“singleInstance” 模式,切换到悬浮框调用 moveTaskToBack(true)方法,能启动小窗口,通话页面退到后台。调试中发现的问题通话界面按home键,之前的activity销毁了,日志发现走了onDestroy,重新点击app图标,MainActivity相关页面重新onCreate(相当于重新启动app了)。因为通话页面是singleInstance模式,此时有两个任务栈,按Home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。该问题解决方案给通话界面设置taskAffinity,如果不设置的话,按下home键时系统会清理最近不活动的和application相同的taskAffinity的所有处于后台的栈,taskAffinity默认与application是同一个。给通话页面设置taskAffinity之后,MainActivity所在后台栈就不会被清理。需要注意:若想在taskAffinity属性生效,需要在启动该Activity时设置Flag为FLAG_ACTIVITY_NEW_TASK。封装库:https://github.com/yangchong211/YCAppTool公共组件层:https://github.com/yangchong211/YCCommonLib
目录介绍01.图片基础概念介绍1.1 图片占用内存介绍1.2 加载网络图片流程1.3 三方库加载图片逻辑1.4 从网络直接拉取图片1.5 加载图片的流程1.6 Bitmap能直接存储吗1.7 Bitmap创建流程1.8 图片框架如何设计02.图片内存计算方式2.1 如何计算占用内存2.2 上面计算内存对吗2.3 一个像素占用内存2.4 使用API获取内存2.5 影响Bitmap内存因素2.6 加载xhdpi和xxhdpi图片2.7 图片一些注意事项03.大图的内存优化3.1 常见图片压缩3.2 图片尺寸压缩3.3 图片质量压缩3.4 双线性采样压缩3.5 高清图分片加载3.6 图片综合压缩04.色彩格式及内存优化4.1 RGB颜色种类4.2 ARGB色彩模式4.3 改变色彩格式优化05.缓存的使用实践优化5.1 Lru内存缓存5.2 Lru内存注意事项5.3 使用Lru磁盘缓存06.不同版本对Bitmap管理6.1 演变进程6.2 管理Bitmap内存6.3 提高Bitmap复用07.图片其他方面优化7.1 减少PNG图片的使用7.2 控件切割圆角优化7.3 如何给图片置灰色7.4 如何处理图片旋转呢7.5 保存图片且刷相册7.6 统一图片域名优化7.7 优化H5图片加载7.8 优化图片阴影效果7.9 图片资源的压缩01.图片基础概念介绍1.1 图片占用内存介绍移动设备的系统资源有限,所以应用应该尽可能的降低内存的使用。在应用运行过程中,Bitmap (图片)往往是内存占用最大的一个部分,Bitmap 图片的加载和处理,通常会占用大量的内存空间,所以在操作 Bitmap 时,应该尽可能的小心。Bitmap 会消耗很多的内存,特别是诸如照片等内容丰富的大图。例如,一个手机拍摄的 2700 1900 像素的照片,需要 5.1M 的存储空间,但是在图像解码配置 ARGB_8888 时,它加载到内存需要 19.6M 内存空间(2592 1936 * 4 bytes),从而迅速消耗掉该应用的剩余内存空间。OOM 的问题也是我们常见的严重问题,OOM 的产生的一个主要场景就是在大图片分配内存的时候产生的,如果 APP 可用内存紧张,这时加载了一张大图,内存空间不足以分配该图片所需要的内存,就会产生 OOM,所以控制图片的高效使用是必备技能。1.2 加载网络图片流程这一部分压缩和缓存图片,在glide源码分析的文章里已经做出了比较详细的说明。在这里简单说一下图片请求加载过程……在使用App的时候,会经常需要加载一些网络图片,一般的操作步骤大概是这样的:第一步从网络加载图片:一般都是通过网络拉取的方式去服务器端获取到图片的文件流后,再通过BitmapFactory.decodeStream(InputStream)来加载图片Bitmap。第二步这种压缩图片:网络加载图片方式加载一两张图片倒不会出现问题,但是如果短时间内加载十几张或者几十张图片的时候,就很有可能会造成OOM(内存溢出),因为现在的图片资源大小都是非常大的,所以我们在加载图片之前还需要进行相应的图片压缩处理。第三步变换图片:比如需要裁剪,切割圆角,旋转,添加高斯模糊等属性。第四步缓存图片:但又有个问题来了,在使用移动数据的情况下,如果用户每次进入App的时候都会去进行网络拉取图片,这样就会非常的浪费数据流量,这时又需要对图片资源进行一些相应的内存缓存以及磁盘缓存处理,这样不仅节省用户的数据流量,还能加快图片的加载速度。第五步异步加载:虽然利用缓存的方式可以加快图片的加载速度,但当需要加载很多张图片的时候(例如图片墙瀑布流效果),就还需用到多线程来加载图片,使用多线程就会涉及到线程同步加载与异步加载问题。1.3 三方库加载图片逻辑先说出结论,目前市面较为常用的大概是Glide,Picasso,Fresco等。大概的处理图片涉及主要逻辑有:从网络或者本地等路径拉取图片;然后解码图片;然后进行压缩;接着会有图片常用圆角,模糊或者裁剪等处理;然后三级缓存加载的图片;当然加载图片过程涉及同步加载和异步加载;最后设置到具体view控件上。1.4 从网络直接拉取图片直接通过网络请求将网络图片转化成bitmap在这将采用最原生的网络请求方式HttpURLConnection方式进行图片获取。经过测试,请求8张图片,耗时毫秒值174。一般是通过get请求拉取图片的。这种方法应该是最基础的网络请求,大家也可以回顾一下,一般开发中很少用这种方式加载图片。具体可以看:ImageToolLib如何加载一个图片呢?可以看看BitmapFactory类为我们提供了四类方法来加载Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;也就是说Bitmap,Drawable,InputStream,Byte[] 之间是可以进行转换。1.5 加载图片的流程搞清楚一个图片概念在电脑上看到的 png 格式或者 jpg 格式的图片,png(jpg) 只是这张图片的容器。是经过相对应的压缩算法将原图每个像素点信息转换用另一种数据格式表示。加载图片显示到手机通过代码,将这张图片加载进内存时,会先解析(也就是解码操作)图片文件本身的数据格式,然后还原为位图,也就是 Bitmap 对象。图片大小vs图片内存大小一张 png 或者 jpg 格式的图片大小,跟这张图片加载进内存所占用的大小完全是两回事。1.6 Bitmap能直接存储吗Bitmap基础概念Bitmap对象本质是一张图片的内容在手机内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。Bitmap本质上不能直接存储为什么?bitmap是一个对象,如果要存储成本地可以查看的图片文件,则必须对bitmap进行编码,然后通过io流写到本地file文件上。1.7 Bitmap创建流程对于图片OOM,可以发现一个现象。heapsize(虚拟机的内存配置)越大越不容易 OOM,Android8.0 及之后的版本更不容易 OOM,这个该如何理解呢?Bitmap对象内存的变化:在 Android 8.0 之前,Bitmap 像素占用的内存是在 Java heap 中分配的;8.0 及之后,Bitmap 像素占用的内存分配到了 Native Heap。由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以推测 OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。搞清楚Bitmap对象内存分配Bitmap 的构造方法是不公开的,在使用 Bitmap 的时候,一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。以 Bitmap.createBitmap 说明了 Bitmap 对象的主要创建过程分析,可以看到 Java Bitmap 对象是在 Native 层通过 NewObject 创建的。allocateJavaPixelRef,是 8.0 之前版本为 Bitmap 像素从 Java heap 申请内存。其核心原理是Bitmap 的像素是保存在 Java 堆上。allocateHeapBitmap,是 8.0 版本为 Bitmap 像素从 Native heap 申请内存。其核心原理主要是通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上。更多详细内容可以看:Bitmap对象内存分配1.8 图片框架如何设计大多数图片框架加载流程概括来说,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操作。图片框架是如何设计的封装参数:从指定来源,到输出结果,中间可能经历很多流程,所以第一件事就是封装参数,这些参数会贯穿整个过程;解析路径:图片的来源有多种,格式也不尽相同,需要规范化;比如glide可以加载file,io,id,网络等各种图片资源读取缓存:为了减少计算,通常都会做缓存;同样的请求,从缓存中取图片(Bitmap)即可;查找文件/下载文件:如果是本地的文件,直接解码即可;如果是网络图片,需要先下载;比如glide这块是发起一个请求解码:这一步是整个过程中最复杂的步骤之一,有不少细节;比如glide中解析图片数据源,旋转方向,图片头等信息变换和压缩:解码出Bitmap之后,可能还需要做一些变换处理(圆角,滤镜等),还要做图片压缩;缓存:得到最终bitmap之后,可以缓存起来,以便下次请求时直接取结果;比如glide用到三级缓存显示:显示结果,可能需要做些动画(淡入动画,crossFade等);比如glide设置显示的时候可以添加动画效果02.图片内存计算方式2.1 如何计算占用内存如果图片要显示下Android设备上,ImageView最终是要加载Bitmap对象的,就要考虑单个Bitmap对象的内存占用了,如何计算一张图片的加载到内存的占用呢?其实就是所有像素的内存占用总和:bitmap内存大小 = 图片长度 x 图片宽度 x 单位像素占用的字节数起决定因素就是最后那个参数了,Bitmap常见有2种编码方式:ARGB_8888和RGB_565,ARGB_8888每个像素点4个byte,RGB_565是2个byte,一般都采用ARGB_8888这种。那么常见的1080*1920的图片内存占用就是:1920 x 1080 x 4 = 7.9M2.2 上面计算内存对吗我看到好多博客都是这样计算的,但是这样算对吗?有没有哥们试验过这种方法正确性?我觉得看博客要对博主表示怀疑,论证别人写的是否正确。说出我的结论:上面2.1这种说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。接下来看看源代码。inDensity默认为图片所在文件夹对应的密度;inTargetDensity为当前系统密度。加载一张本地资源图片,那么它占用的内存 = width height nTargetDensity/inDensity 一个像素所占的内存。@Nullable public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value, @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) { validate(opts); if (opts == null) { opts = new Options(); } if (opts.inDensity == 0 && value != null) { final int density = value.density; if (density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; } else if (density != TypedValue.DENSITY_NONE) { opts.inDensity = density; } } if (opts.inTargetDensity == 0 && res != null) { opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); }正确说法,这个注意呢?计算公式如下所示对资源文件:width height nTargetDensity/inDensity nTargetDensity/inDensity 一个像素所占的内存;别的:width height 一个像素所占的内存;2.3 一个像素占用内存一个像素占用多大内存?Bitmap.Config用来描述图片的像素是怎么被存储的?ARGB_8888: 每个像素4字节. 共32位,默认设置。Alpha_8: 只保存透明度,共8位,1字节。ARGB_4444: 共16位,2字节。RGB_565:共16位,2字节,只存储RGB值。2.4 使用API获取内存Bitmap使用API获取内存getByteCount()getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。getAllocationByteCount()API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。思考: getByteCount()与getAllocationByteCount()的区别?一般情况下两者是相等的;通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。在复用Bitmap的情况下,getAllocationByteCount()可能会比getByteCount()大。2.5 影响Bitmap内存因素影响Bitmap占用内存的因素:图片最终加载的分辨率;图片的格式(PNG/JPEG/BMP/WebP);图片所存放的drawable目录;图片属性设置的色彩模式;设备的屏幕密度;2.6 加载xhdpi和xxhdpi图片提个问题,加载xhdpi和xxhdpi中相同的图片,显示在控件上会一样吗?内存大小一样吗?为什么?肯定是不一样的。xhdpi:240dpi--320dpi,xxhdpi:320dpi--480dpi,app中设置的图片是如何从hdpi中查找的?首先计算dpi,比如手机分辨率是1920x1080,5.1寸的手机。那么得到的dpi公式是(√ ̄1920² + 1080²)/5.1 =2202/5.1= 431dpi。这样优先查找xxhdpi如果xxhdpi里没有查找图片,如果没有会往上找,遵循“先高再低”原则。如果xhdpi里有这个图片会使用xhdpi里的图片,这时候发现会比在xhdpi里的图片放大了。为何要引入不同hdpi的文件管理?比如:xxhdpi放94x94,xhdpi放74x74,hdpi放45x45,这样不管是什么样的手机图片都能在指定的比例显示。引入多种hdpi是为了让这个图片在任何手机上都是手机的这个比例。2.7 图片一些注意事项同样图片显示在大小不相同的ImageView上,内存是一样吗?图片占据内存空间大小与图片在界面上显示的大小没有关系。图片放在res不同目录,加载的内存是一样的吗?最终图片加载进内存所占据的大小会不一样,因为系统在加载 res 目录下的资源图片时,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是:新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi )03.大图的内存优化3.1 常见图片压缩常见压缩方法ApiBitmap.compress(),质量压缩,不会对内存产生影响;BitmapFactory.Options.inSampleSize,内存压缩;Bitmap.compress()质量压缩质量压缩,不会对内存产生影响。它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。BitmapFactory.Options.inSampleSize内存压缩解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。3.2 图片尺寸压缩3.2.1 如何理解尺寸压缩通常在大多数情况下,图片的实际大小都比需要呈现的尺寸大很多。例如,我们的原图是一张 2700 1900 像素的照片,加载到内存就需要 19.6M 内存空间,但是,我们需要把它展示在一个列表页中,组件可展示尺寸为 270 190,这时,我们实际上只需要一张原图的低分辨率的缩略图即可(与图片显示所对应的 UI 控件匹配),那么实际上 270 * 190 像素的图片,只需要 0.2M 的内存即可。可以看到,优化前后相差了 98 倍,原来显示 1 张,现在可以显示 98 张图片,效果非常显著。既然在对原图缩放可以显著减少内存大小,那么我们应该如何操作呢?先加载到内存,再进行操作吗,可以如果先加载到内存,好像也不太对,这样只接占用了 19.6M + 0.2M 2份内存了,而我们想要的是,在原图不加载到内存中,只接将缩放后的图片加载到内存中,可以实现吗?BitmapFactory 提供了从不同资源创建 Bitmap 的解码方法:decodeByteArray()、decodeFile()、decodeResource() 等。但是,这些方法在构造位图的时候会尝试分配内存,也就是它们会导致原图直接加载到内存了,不满足我们的需求。我们可以通过 BitmapFactory.Options 设置一些附加的标记,指定解码选项,以此来解决该问题。如何操作呢?答案来了:将 inJustDecodeBounds 属性设置为 true,可以在解码时避免内存的分配,它会返回一个 null 的 Bitmap ,但是可以获取 outWidth、outHeight 和 outMimeType 值。利用该属性,我们就可以在图片不占用内存的情况下,在图片压缩之前获取图片的尺寸。怎样才能对图片进行压缩呢?通过设置BitmapFactory.Options中inSampleSize的值就可以实现。其计算方式大概就是:计算出实际宽高和目标宽高的比率,然后选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高。3.2.2 设置BitmapFactory.Options属性大概步骤如下所示要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。注意这个地方是核心,这个解析图片并没有生成bitmap对象(也就是说没有为它分配内存控件),而仅仅是拿到它的宽高等属性。然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。这一步会压缩图片。之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了。此时才正式创建了bitmap对象,由于前面已经对它压缩了,所以你会发现此时所占内存大小已经很少了。具体的实现代码public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { // 第一次解析将inJustDecodeBounds设置为true,来获取图片大小 final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options); // 调用上面定义的方法计算inSampleSize值 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 使用获取到的inSampleSize值再次解析图片 options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); }思考:inJustDecodeBounds这个参数是干什么的?如果设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数了。为何设置两次inJustDecodeBounds属性?第一次:设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数。第二次:将inJustDecodeBounds设置为false再次调用decode函数时就能生成bitmap了。而此时的bitmap已经压缩减小很多了,所以加载到内存中并不会导致OOM。3.3 图片质量压缩在Android中,对图片进行质量压缩,通常我们的实现方式如下所示://quality 为0~100,0表示最小体积,100表示最高质量,对应体积也是最大 bitmap.compress(Bitmap.CompressFormat.JPEG, quality , outputStream);在上述代码中,我们选择的压缩格式是CompressFormat.JPEG,除此之外还有两个选择:其一,CompressFormat.PNG,PNG格式是无损的,它无法再进行质量压缩,quality这个参数就没有作用了,会被忽略,所以最后图片保存成的文件大小不会有变化;其二,CompressFormat.WEBP,这个格式是google推出的图片格式,它会比JPEG更加省空间,经过实测大概可以优化30%左右。Android质量压缩逻辑,函数compress经过一连串的java层调用之后,最后来到了一个native函数:具体看:Bitmap.cpp,最后调用了函数encoder->encodeStream(…)编码保存本地。该函数是调用skia引擎来对图片进行编码压缩。3.4 双线性采样压缩双线性采样(Bilinear Resampling)在 Android 中的使用方式一般有两种:bm = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true); //或者直接使用 matrix 进行缩放 Matrix matrix = new Matrix(); matrix.setScale(0.5f, 0.5f); bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true);看源码可以知道createScaledBitmap函数最终也是使用第二种方式的matrix进行缩放双线性采样使用的是双线性內插值算法,这个算法不像邻近点插值算法一样,直接粗暴的选择一个像素,而是参考了源像素相应位置周围2x2个点的值,根据相对位置取对应的权重,经过计算之后得到目标图像。3.5 高清图分片加载适用场景 : 当一张图片非常大 , 在手机中只需要显示其中一部分内容 , BitmapRegionDecoder 非常有用 。主要作用 : BitmapRegionDecoder 可以从图像中 解码一个矩形区域 。相当于手在滑动的过程中,计算当前显示区域的图片绘制出来。基本使用流程 : 先创建,后解码 。调用 newInstance 方法 , 创建 BitmapRegionDecoder 对象 ;然后调用 decodeRegion 方法 , 获取指定 Rect 矩形区域的解码后的 Bitmap 对象3.6 图片综合压缩一般情况下图片综合压缩的整体思路如下:第一步进行采样率压缩;第二步进行宽高的等比例压缩(微信对原图和缩略图限制了最大长宽或者最小长宽);第三步就是对图片的质量进行压缩(一般75或者70);第四步就是采用webP的格式。关于图片压缩的综合案例如下具体可以参考:CompressServer04.色彩格式及内存优化4.1 RGB颜色种类RGB 色彩模式是工业界的一种颜色标准通过对红(R)、绿(G)、蓝(B)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的,RGB即是代表红、绿、蓝三个通道的颜色,这个标准几乎包括了人类视力所能感知的所有颜色,是运用最广的颜色系统之一。Android 中,像素的存储方式使用的色彩模式正是 RGB 色彩模式。4.2 ARGB色彩模式在 Android 中,我们常见的一些颜色设置,都是 RGB 色彩模式来描述像素颜色的,并且他们都带有透明度通道,也就是所谓的 ARGB。例如,我们常见的颜色定义如下://在代码中定义颜色值:蓝色 public final int blue=0xff0000ff; //或者在xml中定义: <drawable name="blue">#ff0000ff</drawable> 以上设置中,颜色值都是使用 16 进制的数字来表示的。以上颜色值都是带有透明度(透明通道)的颜色值,格式是 AARRGGBB,透明度、红色、绿色、蓝色四个颜色通道,各占有 2 位,也就是一个颜色通道,使用了 1 个字节来存储。4.3 改变色彩格式优化Android 中有多种 RGB 模式,我们可以设置不同的格式,来控制图片像素颜色的显示质量和存储空间。Android.graphics.Bitmap 类里有一个内部类 Bitmap.Config 类,它定义了可以在 Android 中使用的几种色彩格式:public enum Config { ALPHA_8 (1), RGB_565 (3), @Deprecated ARGB_4444 (4), ARGB_8888 (5), RGBA_F16 (6), HARDWARE (7); }解释一下这几个值分别代表了什么含义?我们已经知道了:A 代表透明度、R 代表红色、G 代表绿色、B 代表蓝色。ALPHA_8:表示,只存在 Alpha 通道,没有存储色彩值,只含有透明度,每个像素占用 1 个字节的空间。RGB_565:表示,R 占用 5 位二进制的位置,G 占用了6位,B 占用了 5 位。每个像素占用 2 个字节空间,并且不包含透明度。ARGB_4444:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 4 个 bit 位。每个像素占用 2 个字节空间。ARGB_8888:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 8 个 bit 位。每个像素占用 4 个字节空间。RGBA_F16:表示,每个像素存储在8个字节上。此配置特别适合广色域和HDR内容。HARDWARE:特殊配置,当位图仅存储在图形内存中时。 此配置中的位图始终是不可变的。那么开发中一般选择哪一种比较合适呢Android 中的图片在加载时,默认的色彩格式是 ARGB_8888,也就是每个像素占用 4 个字节空间,一张 2700 1900 像素的照片,加载到内存就需要 19.6M 内存空间(2592 1936 * 4 bytes)。如果图片在 UI 组件中显示时,不需要太高的图片质量,例如显示一张缩略图(不透明图片)等场景,这时,我们就没必要使用 ARGB_8888 的色彩格式了,只需要使用 RGB_565 模式即可满足显示的需要。那么,我们的优化操作就可以是:将 2700 1900 像素的原图,压缩到原图的低分辨率的缩略图 270 190 像素的图片,这时需要 0.2M 的内存。也就是从 19.6M内存,压缩为 0.2 M内存。我们还可以进一步优化色彩格式,由 ARGB_8888 改为 RGB_565 模式,这时,目标图片需要的内存就变为 270 190 2 = 0.1M 了。图片内存空间又减小了一倍。05.缓存的使用实践优化5.1 Lru内存缓存LruCache 类特别适合用来缓存 Bitmap,它使用一个强引用的 LinkedHashMap 保存最近引用的对象,并且在缓存超出设定大小时,删除最近最少使用的对象。给 LruCache 确定一个合适的缓存大小非常重要,我们需要考虑几个因素:应用剩余多少可用内存?需要有多少张图片同时显示到屏幕上?有多少图片需要准备好以便马上显示到屏幕?设备的屏幕大小和密度是多少?高密度的设备需要更大的缓存空间来缓存同样数量的图片。Bitmap 的尺寸配置是多少,花费多少内存?图片被访问的频率如何?如果其中一些比另外一些访问更频繁,那么我们可能希望在内存中保存那些最常访问的图片,或者根据访问频率给 Bitmap 分组,为不同的 Bitmap 组设置多个 LruCache 对象。是否可以在缓存图片的质量和数量之间寻找平衡点?有时,保存大量低质量的 Bitmap 会非常有用,加载更高质量的图片的任务可以交给另外一个后台线程处理。缓存太小会导致额外的花销却没有明显的好处,缓存太大同样会导致 java.lang.OutOfMemory 的异常,并且使得你的程序只留下小部分的内存用来工作(缓存占用太多内存,导致其他操作会因为内存不够而抛出异常)。所以,我们需要分析实际情况之后,提出一个合适的解决方案。LruCache是Android提供的一个缓存类,通常运用于内存缓存LruCache是一个泛型类,它的底层是用一个LinkedHashMap以强引用的方式存储外界的缓存对象来实现的。为什么使用LinkedHashMap来作为LruCache的存储,是因为LinkedHashMap有两种排序方式,一种是插入排序方式,一种是访问排序方式,默认情况下是以访问方式来存储缓存对象的;LruCache提供了get和put方法来完成缓存的获取和添加,当缓存满时,会将最近最少使用的对象移除掉,然后再添加新的缓存对象。如下源码所示,底层是LinkedHashMap。public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize; this.map = new LinkedHashMap<K, V>(0, 0.75f, true); }在使用LruCache的时候,首先需要获取当前设备的内存容量,通常情况下会将总容量的八分之一作为LruCache的容量,然后重写LruCache的sizeof方法,sizeof方法用于计算缓存对象的大小,单位需要与分配的容量的单位一致;// 获取系统最大缓存 int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // set LruCache size; // 使用最大可用内存值的1/8作为缓存的大小 int cacheSize = maxMemory / 8; LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(@NonNull String uri, @NonNull Bitmap bitmap) { // 重写此方法来衡量每张图片的大小,默认返回图片数量 return bitmap.getRowBytes() * bitmap.getHeight() / 1024; } }; //插入对象 memoryCache.put(key, bitmap); //取出对象 memoryCache.get(key);如何淘汰缓存这个就要看LinkedHashMap集合的特点呢!LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。在进行淘汰时,移除遍历器前端的元素,直至缓存总大小降低到指定大小以下。5.2 Lru内存注意事项看一个真实的场景假设我们的LruCache可以缓存80张,每次刷新从网络获取20张图片且不重复,那么在刷新第五次的时候,根据LruCache缓存的规则,第一次刷新的20张图片就会从LruCache中移出,处于等待被系统GC的状态。如果我们继续刷新n次,等待被回收的张数就会累积到 20 * n 张。会出现什么问题会出现大量的Bitmap内存碎片,我们不知道系统什么时候会触发GC回收掉这些无用的Bitmap,对于内存是否会溢出,是否会频繁GC导致卡顿等未知问题。解决方案该怎么做?第一种:在3.0以后引入了 BitmapFactory.Options.inBitmap,如果设置此项,需要解码的图片就会尝试使用该Bitmap的内存,这样取消了内存的动态分配,提高了性能,节省了内存。第二种:把处于无用的状态的Bitmap放入SoftReference。SoftReference引用的对象会在内存溢出之前被回收。关于Lru缓存案例和代码可以参考:AppLruCache5.3 使用Lru磁盘缓存内存缓存能够提高访问最近用过的 Bitmap 的速度,但是我们无法保证最近访问过的 Bitmap 都能够保存在缓存中。像类似 GridView 等需要大量数据填充的控件很容易就会用尽整个内存缓存。另外,我们的应用可能会被类似打电话等行为而暂停并退到后台,因为后台应用可能会被杀死,那么内存缓存就会被销毁,里面的 Bitmap 也就不存在了。一旦用户恢复应用的状态,那么应用就需要重新处理那些图片。磁盘缓存可以用来保存那些已经处理过的 Bitmap,它还可以减少那些不再内存缓存中的 Bitmap 的加载次数。当然从磁盘读取图片会比从内存要慢,而且由于磁盘读取操作时间是不可预期的,读取操作需要在后台线程中处理。注意:如果图片会被更频繁的访问,使用 ContentProvider 或许会更加合适,比如在图库应用中。注意:因为初始化磁盘缓存涉及到 I/O 操作,所以它不应该在主线程中进行。但是这也意味着在初始化完成之前缓存可以被访问。为了解决这个问题,在上面的实现中,有一个锁对象(lock object)来确保在磁盘缓存完成初始化之前,应用无法对它进行读取。内存缓存的检查是可以在 UI 线程中进行的,磁盘缓存的检查需要在后台线程中处理。磁盘操作永远都不应该在 UI 线程中发生。当图片处理完成后,Bitmap 需要添加到内存缓存与磁盘缓存中,方便之后的使用。06.不同版本对Bitmap管理6.1 演变进程Android 2.3.3 (API level 10)以及之前,一个 Bitmap 的像素数据是存放在 Native 内存空间中的。这些数据与 Bitmap 对象本身是隔离的,Bitmap 本身被存放在 Dalvik 堆中。并且无法预测在 Native 内存中的像素级数据何时会被释放,这意味着程序容易超过它的内存限制并且崩溃。Android 3.0 (API Level 11)开始像素数据则是与 Bitmap 本身一起存放在 Dalvik 堆中。Android 8.0(Android O)及之后的版本中Bitmap 的像素数据的内存分配又回到了 Native 层,它是在 Native 堆空间进行分配的。6.2 管理Bitmap内存管理 Android 2.3.3 及以下版本的内存使用在 Android 2.3.3 (API level 10) 以及更低版本上,推荐使用 recycle() 方法。 如果在应用中显示了大量的 Bitmap 数据,我们很可能会遇到 OutOfMemoryError 的错误。 recycle() 方法可以使得程序更快的释放内存。管理 Android 3.0 及其以上版本的内存从 Android 3.0 (API Level 11)开始,引进了 BitmapFactory.Options.inBitmap 字段。 如果使用了这个设置字段,decode 方法会在加载 Bitmap 数据的时候去重用已经存在的 Bitmap。这意味着 Bitmap 的内存是被重新利用的,这样可以提升性能,并且减少了内存的分配与回收。然而,使用 inBitmap 有一些限制,特别是在Android 4.4 (API level 19)之前,只有同等大小的位图才可以被重用。管理 Android 8.0 及其以上版本的内存在 Android 8.0 及其以上版本,处理内存,也遵循 Android 3.0 以上版本同样的方式。同时,图片像素数据存储在 native 层,并且不占用 Java 堆的空间,这也代表着我们拥有更大的图片存储空间,可以加载质量更高、数据更多的图片到内存中。但是,内存依然不是无限的,应用还是要受到手机内存的限制,所以一定要注意这一点。6.3 提高Bitmap复用Android3.0之后,并没有强调Bitmap.recycle();而是强调Bitmap的复用。使用LruCache对Bitmap进行缓存,当再次使用到这个Bitmap的时候直接获取,而不用重走编码流程。Android3.0(API 11之后)引入了BitmapFactory.Options.inBitmap字段,设置此字段之后解码方法会尝试复用一张存在的Bitmap。这意味着Bitmap的内存被复用,避免了内存的回收及申请过程,显然性能表现更佳。使用这个字段有几点限制:声明可被复用的Bitmap必须设置inMutable为true;Android4.4(API 19)之前只有格式为jpg、png,同等宽高(要求苛刻),inSampleSize为1的Bitmap才可以复用;Android4.4(API 19)之前被复用的Bitmap的inPreferredConfig会覆盖待分配内存的Bitmap设置的inPreferredConfig;Android4.4(API 19)之后被复用的Bitmap的内存必须大于需要申请内存的Bitmap的内存;Android4.4(API 19)之前待加载Bitmap的Options.inSampleSize必须明确指定为1。Bitmap复用的实验,代码如下所示,然后看打印的日志信息从内存地址的打印可以看出,两个对象其实是一个对象,Bitmap复用成功;bitmapReuse占用的内存(4346880)正好是bitmap占用内存(1228800)的四分之一;getByteCount()获取到的是当前图片应当所占内存大小,getAllocationByteCount()获取到的是被复用Bitmap真实占用内存大小。虽然bitmapReuse的内存只有4346880,但是因为是复用的bitmap的内存,因而其真实占用的内存大小是被复用的bitmap的内存大小(1228800)。这也是getAllocationByteCount()可能比getByteCount()大的原因。@RequiresApi(api = Build.VERSION_CODES.KITKAT) private void initBitmap() { BitmapFactory.Options options = new BitmapFactory.Options(); // 图片复用,这个属性必须设置; options.inMutable = true; // 手动设置缩放比例,使其取整数,方便计算、观察数据; options.inDensity = 320; options.inTargetDensity = 320; Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bg_autumn_tree_min, options); // 对象内存地址; Log.i("ycBitmap", "bitmap = " + bitmap); Log.i("ycBitmap", "ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount()); // 使用inBitmap属性,这个属性必须设置; options.inBitmap = bitmap; options.inDensity = 320; // 设置缩放宽高为原始宽高一半; options.inTargetDensity = 160; options.inMutable = true; Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.bg_kites_min, options); // 复用对象的内存地址; Log.i("ycBitmap", "bitmapReuse = " + bitmapReuse); Log.i("ycBitmap", "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount()); Log.i("ycBitmap", "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount()); //11-26 18:24:07.971 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap = android.graphics.Bitmap@9739bff //11-26 18:24:07.972 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 4346880:::bitmap:AllocationByteCount = 4346880 //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse = android.graphics.Bitmap@9739bff //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 1228800:::bitmap:AllocationByteCount = 4346880 //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse:ByteCount = 1228800:::bitmapReuse:AllocationByteCount = 4346880 }07.图片其他方面优化7.1 减少PNG图片的使用这里要介绍一种新的图片格式:Webp,它是由 Google 推出的一种既保留 png 格式的优点,又能够减少图片大小的一种新型图片格式。在 Android 4.0(API level 14) 中支持有损的 WebP 图像,在 Android 4.3(API level 18) 和更高版本中支持无损和透明的 WebP 图像。注意一下,Webp格式图片仅仅只是减少图片的质量大小,并不会减少加载图片后的内存占用。7.2 切割圆角优化方案1:直接采用Canvas.clipPath 相关api,裁剪出一个圆角区域。该方案简单暴力,通用性强。如果只是一个静态的单图视图,该方法问题不大,但如果是复杂页面,滚动的时候,测试就会跟你说,页面卡顿了,要优化。原因就是 Canvas.clip的相关api损耗相对较大。方案2:系统提供的CardView设置圆角把原来全工程各个视频控件和图片控件的外层,都加上一层CardView。改造成本大,布局层级更深一层,layout时间加长。方案3:使用setXfermode法此种方式就是再new一个相同尺寸的bitmap,然后使用paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));先画圆角矩形,再画原始bitmap,然后就得到了一个圆角的bitmap。早期用得较多,占用bitmap双倍内存。方案4:图片加载库比如Glide,Fresco等在底层,无非也是使用上面的这两种种方式。早期的使用setXfermode来实现,后来使用BitmapShader实现。使用简单,稳定。方案5:遮罩还是使用setXfermode,不过与方法一不同的是:不对图片作任何更改,只在圆角之外再画一层与背景颜色相同的四个角来遮挡,在视觉上造成圆角图片的效果。那个切割圆角该怎么优化呢?使用方案3,可以采用自定义view,支持LinearLayout、RelativeLayout、FrameLayout、ConstraintLayout、ImageView、TextView、View、Button等设置圆角。具体案例可见:RoundCorners7.3 如何给图片置灰色大概的操作步骤。具体可以参考:PicCalculateUtils第一步:获取原始图片的宽高,然后创建一个bitmap可变位图对象。第二步:创建画板canvas对象,然后创建画笔paint。然后调用canvas.drawBitmap方法绘制图片第三步:对画笔进行修饰,设置画笔颜色属性,这里使用到了ColorMatrix,核心就是设置饱和度为0,即可绘制灰色内容7.4 如何处理图片旋转呢在Android中使用ImageView显示图片的时候发现图片显示不正,方向偏了或者倒过来了。解决这个问题很自然想到的两步走,首先是要自动识别图像方向,计算旋转角度,然后对图像进行旋转并显示。识别图像方向首先在这里提一个概念EXIF(Exchangeable Image File Format,可交换图像文件)。简而言之,Exif是一个标准,用于电子照相机(也包括手机、扫描器等)上,用来规范图片、声音、视屏以及它们的一些辅助标记格式。Exif支持的格式如下:图像;压缩图像文件:JPEG、DCT;非压缩图像文件:TIFF;音频;RIFF、WAVAndroid提供了对JPEG格式图像Exif接口支持,可以读取JPEG文件metadata信息,参见ExifInterface。这些Metadata信息总的来说大致分为三类:日期时间、空间信息(经纬度、高度)、Camera信息(孔径、焦距、旋转角、曝光量等等)。关于图像旋转获取了图片的旋转方向后,然后再设置图像旋转。最后Bitmap提供的静态createBitmap方法,可以对图片设置旋转角度。具体看:PicCalculateUtils7.5 保存图片且刷相册大概的操作步骤如下所示。具体可看:ImageSaveUtils第一步:创建图片文件,然后将bitmap对象写到图片文件中第二步:通过MediaStore将图片插入到共享目录相册中第三步:发送通知,通知相册中刷新插入图片的数据。注意,获取图片资源uri刷新即可,避免刷新所有数据造成等待时间过长。7.6 统一图片域名优化域名统一减少了10%+的重复图片下载和内存消耗。同时减少之前多域名图片加载时重复创建HTTPS请求的过程,减少图片加载时间。7.7 优化H5图片加载通过拦截WebView图片加载的方式,让原生图片库来下载图片之后传递图片二进制数据给WebView显示。采用OkHttp拦截资源缓存,下面是大概的思路。缓存的入口从shouldInterceptRequest出发第一步,拿到WebResourceRequest对象中请求资源的url还有header,如果开发者设置不缓存则返回null第二步,如果缓存,通过url判断拦截资源的条件,过滤非http,音视频等资源,这个是可自由配置缓存内容比如css,png,jpg,xml,txt等第三步,判断本地是否有OkHttp缓存数据,如果有则直接读取本地资源,通过url找到对应的path路径,然后读取文件流,组装数据返回。第四步,如果没有缓存数据,创建OkHttp的Request请求,将资源网络请求交给okHttp来处理,并且用它自带的缓存功能,当然如果是请求失败或者异常则返回null,否则返回正常数据关于webView图片缓存的方案,可以直接参考:YCWebView7.8 优化图片阴影效果阴影效果有哪些实现方式第一种:使用CardView,但是不能设置阴影颜色第二种:采用shape叠加,存在后期UI效果不便优化第三种:UI切图第四种:自定义View第五种:自定义Drawable否定上面前两种方案原因分析?第一个方案的CardView渐变色和阴影效果很难控制,只能支持线性或者环装形式渐变,这种不满足需要,因为阴影本身是一个四周一层很淡的颜色包围,在一个矩形框的层面上颜色大概一致,而且这个CardView有很多局限性,比如不能修改阴影的颜色,不能修改阴影的深浅。所以这个思路无法实现这个需求。第二个采用shape叠加,可以实现阴影效果,但是影响UI,且阴影部分是占像素的,而且不灵活。第三个方案询问了一下ui。他们给出的结果是如果使用切图的话那标注的话很难标,身为一个优秀的设计师大多对像素点都和敏感,界面上的像素点有一点不协调那都是无法容忍的。网上一些介绍阴影效果方案所有在深奥的技术,也都是为需求做准备的。也就是需要实践并且可以用到实际开发中,这篇文章不再抽象介绍阴影效果原理,理解三维空间中如何处理偏移光线达到阴影视差等,网上看了一些文章也没看明白或者理解。这篇博客直接通过调用api实现预期的效果。多个drawable叠加,使用layer-list可以将多个drawable按照顺序层叠在一起显示,默认情况下,所有的item中的drawable都会自动根据它附上view的大小而进行缩放,layer-list中的item是按照顺序从下往上叠加的,即先定义的item在下面,后面的依次往上面叠放阴影是否占位使用CardView阴影不占位,不能设置阴影颜色和效果使用shape阴影是可以设置阴影颜色,但是是占位的几种方案优缺点对比分析CardView 优点:自带功能实现简单 缺点:自带圆角不一定可适配所有需求layer(shape叠加) 优点:实现形式简单 缺点:效果一般自定义实现 优点:实现效果好可配置能力高 缺点:需要开发者自行开发关于解决阴影效果具体各种方案的对比可以参考这个demo:AppShadowLib7.9 图片资源的压缩我们应用中使用的图片,设计师出的原图通常都非常大,他们通常会使用工具,经过一定的压缩,缩减到比较小一些的大小。但是,这些图片通常都有一定的可压缩空间,我在之前的项目中,对图片进行了二次压缩,整体压缩率达到了 40%~50% ,效果还是非常不错的。这里介绍下常用的,图片压缩的方法:使用压缩工具对图片进行二次压缩。根据最终图片是否需要透明度展示,优先选择不透明的图片格式,例如,我们应该避免使用 png 格式的图片。对于色彩简单,例如,一些背景之类的图片,可以选择使用布局文件来定义(矢量图),这样就会非常节省内存了。如果包含透明度,优先使用 WebP 等格式图像。图片在上线前进行压缩处理,不但可以减少内存的使用,如果图片是网络获取的,也可以减少网络加载的流量和时间。推荐一个图片压缩网站:tinypng网站
目录介绍01.图片基础概念介绍1.1 图片占用内存介绍1.2 加载网络图片流程1.3 三方库加载图片逻辑1.4 从网络直接拉取图片1.5 加载图片的流程1.6 Bitmap能直接存储吗1.7 Bitmap创建流程1.8 图片框架如何设计02.图片内存计算方式2.1 如何计算占用内存2.2 上面计算内存对吗2.3 一个像素占用内存2.4 使用API获取内存2.5 影响Bitmap内存因素2.6 加载xhdpi和xxhdpi图片2.7 图片一些注意事项03.大图的内存优化3.1 常见图片压缩3.2 图片尺寸压缩3.3 图片质量压缩3.4 双线性采样压缩3.5 高清图分片加载3.6 图片综合压缩04.色彩格式及内存优化4.1 RGB颜色种类4.2 ARGB色彩模式4.3 改变色彩格式优化05.缓存的使用实践优化5.1 Lru内存缓存5.2 Lru内存注意事项5.3 使用Lru磁盘缓存06.不同版本对Bitmap管理6.1 演变进程6.2 管理Bitmap内存6.3 提高Bitmap复用07.图片其他方面优化7.1 减少PNG图片的使用7.2 控件切割圆角优化7.3 如何给图片置灰色7.4 如何处理图片旋转呢7.5 保存图片且刷相册7.6 统一图片域名优化7.7 优化H5图片加载7.8 优化图片阴影效果7.9 图片资源的压缩01.图片基础概念介绍1.1 图片占用内存介绍移动设备的系统资源有限,所以应用应该尽可能的降低内存的使用。在应用运行过程中,Bitmap (图片)往往是内存占用最大的一个部分,Bitmap 图片的加载和处理,通常会占用大量的内存空间,所以在操作 Bitmap 时,应该尽可能的小心。Bitmap 会消耗很多的内存,特别是诸如照片等内容丰富的大图。例如,一个手机拍摄的 2700 1900 像素的照片,需要 5.1M 的存储空间,但是在图像解码配置 ARGB_8888 时,它加载到内存需要 19.6M 内存空间(2592 1936 * 4 bytes),从而迅速消耗掉该应用的剩余内存空间。OOM 的问题也是我们常见的严重问题,OOM 的产生的一个主要场景就是在大图片分配内存的时候产生的,如果 APP 可用内存紧张,这时加载了一张大图,内存空间不足以分配该图片所需要的内存,就会产生 OOM,所以控制图片的高效使用是必备技能。1.2 加载网络图片流程这一部分压缩和缓存图片,在glide源码分析的文章里已经做出了比较详细的说明。在这里简单说一下图片请求加载过程……在使用App的时候,会经常需要加载一些网络图片,一般的操作步骤大概是这样的:第一步从网络加载图片:一般都是通过网络拉取的方式去服务器端获取到图片的文件流后,再通过BitmapFactory.decodeStream(InputStream)来加载图片Bitmap。第二步这种压缩图片:网络加载图片方式加载一两张图片倒不会出现问题,但是如果短时间内加载十几张或者几十张图片的时候,就很有可能会造成OOM(内存溢出),因为现在的图片资源大小都是非常大的,所以我们在加载图片之前还需要进行相应的图片压缩处理。第三步变换图片:比如需要裁剪,切割圆角,旋转,添加高斯模糊等属性。第四步缓存图片:但又有个问题来了,在使用移动数据的情况下,如果用户每次进入App的时候都会去进行网络拉取图片,这样就会非常的浪费数据流量,这时又需要对图片资源进行一些相应的内存缓存以及磁盘缓存处理,这样不仅节省用户的数据流量,还能加快图片的加载速度。第五步异步加载:虽然利用缓存的方式可以加快图片的加载速度,但当需要加载很多张图片的时候(例如图片墙瀑布流效果),就还需用到多线程来加载图片,使用多线程就会涉及到线程同步加载与异步加载问题。1.3 三方库加载图片逻辑先说出结论,目前市面较为常用的大概是Glide,Picasso,Fresco等。大概的处理图片涉及主要逻辑有:从网络或者本地等路径拉取图片;然后解码图片;然后进行压缩;接着会有图片常用圆角,模糊或者裁剪等处理;然后三级缓存加载的图片;当然加载图片过程涉及同步加载和异步加载;最后设置到具体view控件上。1.4 从网络直接拉取图片直接通过网络请求将网络图片转化成bitmap在这将采用最原生的网络请求方式HttpURLConnection方式进行图片获取。经过测试,请求8张图片,耗时毫秒值174。一般是通过get请求拉取图片的。这种方法应该是最基础的网络请求,大家也可以回顾一下,一般开发中很少用这种方式加载图片。具体可以看:ImageToolLib如何加载一个图片呢?可以看看BitmapFactory类为我们提供了四类方法来加载Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;也就是说Bitmap,Drawable,InputStream,Byte[] 之间是可以进行转换。1.5 加载图片的流程搞清楚一个图片概念在电脑上看到的 png 格式或者 jpg 格式的图片,png(jpg) 只是这张图片的容器。是经过相对应的压缩算法将原图每个像素点信息转换用另一种数据格式表示。加载图片显示到手机通过代码,将这张图片加载进内存时,会先解析(也就是解码操作)图片文件本身的数据格式,然后还原为位图,也就是 Bitmap 对象。图片大小vs图片内存大小一张 png 或者 jpg 格式的图片大小,跟这张图片加载进内存所占用的大小完全是两回事。1.6 Bitmap能直接存储吗Bitmap基础概念Bitmap对象本质是一张图片的内容在手机内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。Bitmap本质上不能直接存储为什么?bitmap是一个对象,如果要存储成本地可以查看的图片文件,则必须对bitmap进行编码,然后通过io流写到本地file文件上。1.7 Bitmap创建流程对于图片OOM,可以发现一个现象。heapsize(虚拟机的内存配置)越大越不容易 OOM,Android8.0 及之后的版本更不容易 OOM,这个该如何理解呢?Bitmap对象内存的变化:在 Android 8.0 之前,Bitmap 像素占用的内存是在 Java heap 中分配的;8.0 及之后,Bitmap 像素占用的内存分配到了 Native Heap。由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以推测 OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。搞清楚Bitmap对象内存分配Bitmap 的构造方法是不公开的,在使用 Bitmap 的时候,一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。以 Bitmap.createBitmap 说明了 Bitmap 对象的主要创建过程分析,可以看到 Java Bitmap 对象是在 Native 层通过 NewObject 创建的。allocateJavaPixelRef,是 8.0 之前版本为 Bitmap 像素从 Java heap 申请内存。其核心原理是Bitmap 的像素是保存在 Java 堆上。allocateHeapBitmap,是 8.0 版本为 Bitmap 像素从 Native heap 申请内存。其核心原理主要是通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上。更多详细内容可以看:Bitmap对象内存分配1.8 图片框架如何设计大多数图片框架加载流程概括来说,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操作。图片框架是如何设计的封装参数:从指定来源,到输出结果,中间可能经历很多流程,所以第一件事就是封装参数,这些参数会贯穿整个过程;解析路径:图片的来源有多种,格式也不尽相同,需要规范化;比如glide可以加载file,io,id,网络等各种图片资源读取缓存:为了减少计算,通常都会做缓存;同样的请求,从缓存中取图片(Bitmap)即可;查找文件/下载文件:如果是本地的文件,直接解码即可;如果是网络图片,需要先下载;比如glide这块是发起一个请求解码:这一步是整个过程中最复杂的步骤之一,有不少细节;比如glide中解析图片数据源,旋转方向,图片头等信息变换和压缩:解码出Bitmap之后,可能还需要做一些变换处理(圆角,滤镜等),还要做图片压缩;缓存:得到最终bitmap之后,可以缓存起来,以便下次请求时直接取结果;比如glide用到三级缓存显示:显示结果,可能需要做些动画(淡入动画,crossFade等);比如glide设置显示的时候可以添加动画效果02.图片内存计算方式2.1 如何计算占用内存如果图片要显示下Android设备上,ImageView最终是要加载Bitmap对象的,就要考虑单个Bitmap对象的内存占用了,如何计算一张图片的加载到内存的占用呢?其实就是所有像素的内存占用总和:bitmap内存大小 = 图片长度 x 图片宽度 x 单位像素占用的字节数起决定因素就是最后那个参数了,Bitmap常见有2种编码方式:ARGB_8888和RGB_565,ARGB_8888每个像素点4个byte,RGB_565是2个byte,一般都采用ARGB_8888这种。那么常见的1080*1920的图片内存占用就是:1920 x 1080 x 4 = 7.9M2.2 上面计算内存对吗我看到好多博客都是这样计算的,但是这样算对吗?有没有哥们试验过这种方法正确性?我觉得看博客要对博主表示怀疑,论证别人写的是否正确。说出我的结论:上面2.1这种说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。接下来看看源代码。inDensity默认为图片所在文件夹对应的密度;inTargetDensity为当前系统密度。加载一张本地资源图片,那么它占用的内存 = width height nTargetDensity/inDensity 一个像素所占的内存。@Nullable public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value, @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) { validate(opts); if (opts == null) { opts = new Options(); } if (opts.inDensity == 0 && value != null) { final int density = value.density; if (density == TypedValue.DENSITY_DEFAULT) { opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; } else if (density != TypedValue.DENSITY_NONE) { opts.inDensity = density; } } if (opts.inTargetDensity == 0 && res != null) { opts.inTargetDensity = res.getDisplayMetrics().densityDpi; } return decodeStream(is, pad, opts); }正确说法,这个注意呢?计算公式如下所示对资源文件:width height nTargetDensity/inDensity nTargetDensity/inDensity 一个像素所占的内存;别的:width height 一个像素所占的内存;2.3 一个像素占用内存一个像素占用多大内存?Bitmap.Config用来描述图片的像素是怎么被存储的?ARGB_8888: 每个像素4字节. 共32位,默认设置。Alpha_8: 只保存透明度,共8位,1字节。ARGB_4444: 共16位,2字节。RGB_565:共16位,2字节,只存储RGB值。2.4 使用API获取内存Bitmap使用API获取内存getByteCount()getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。getAllocationByteCount()API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。思考: getByteCount()与getAllocationByteCount()的区别?一般情况下两者是相等的;通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。在复用Bitmap的情况下,getAllocationByteCount()可能会比getByteCount()大。2.5 影响Bitmap内存因素影响Bitmap占用内存的因素:图片最终加载的分辨率;图片的格式(PNG/JPEG/BMP/WebP);图片所存放的drawable目录;图片属性设置的色彩模式;设备的屏幕密度;2.6 加载xhdpi和xxhdpi图片提个问题,加载xhdpi和xxhdpi中相同的图片,显示在控件上会一样吗?内存大小一样吗?为什么?肯定是不一样的。xhdpi:240dpi--320dpi,xxhdpi:320dpi--480dpi,app中设置的图片是如何从hdpi中查找的?首先计算dpi,比如手机分辨率是1920x1080,5.1寸的手机。那么得到的dpi公式是(√ ̄1920² + 1080²)/5.1 =2202/5.1= 431dpi。这样优先查找xxhdpi如果xxhdpi里没有查找图片,如果没有会往上找,遵循“先高再低”原则。如果xhdpi里有这个图片会使用xhdpi里的图片,这时候发现会比在xhdpi里的图片放大了。为何要引入不同hdpi的文件管理?比如:xxhdpi放94x94,xhdpi放74x74,hdpi放45x45,这样不管是什么样的手机图片都能在指定的比例显示。引入多种hdpi是为了让这个图片在任何手机上都是手机的这个比例。2.7 图片一些注意事项同样图片显示在大小不相同的ImageView上,内存是一样吗?图片占据内存空间大小与图片在界面上显示的大小没有关系。图片放在res不同目录,加载的内存是一样的吗?最终图片加载进内存所占据的大小会不一样,因为系统在加载 res 目录下的资源图片时,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是:新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi )03.大图的内存优化3.1 常见图片压缩常见压缩方法ApiBitmap.compress(),质量压缩,不会对内存产生影响;BitmapFactory.Options.inSampleSize,内存压缩;Bitmap.compress()质量压缩质量压缩,不会对内存产生影响。它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。BitmapFactory.Options.inSampleSize内存压缩解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。3.2 图片尺寸压缩3.2.1 如何理解尺寸压缩通常在大多数情况下,图片的实际大小都比需要呈现的尺寸大很多。例如,我们的原图是一张 2700 1900 像素的照片,加载到内存就需要 19.6M 内存空间,但是,我们需要把它展示在一个列表页中,组件可展示尺寸为 270 190,这时,我们实际上只需要一张原图的低分辨率的缩略图即可(与图片显示所对应的 UI 控件匹配),那么实际上 270 * 190 像素的图片,只需要 0.2M 的内存即可。可以看到,优化前后相差了 98 倍,原来显示 1 张,现在可以显示 98 张图片,效果非常显著。既然在对原图缩放可以显著减少内存大小,那么我们应该如何操作呢?先加载到内存,再进行操作吗,可以如果先加载到内存,好像也不太对,这样只接占用了 19.6M + 0.2M 2份内存了,而我们想要的是,在原图不加载到内存中,只接将缩放后的图片加载到内存中,可以实现吗?BitmapFactory 提供了从不同资源创建 Bitmap 的解码方法:decodeByteArray()、decodeFile()、decodeResource() 等。但是,这些方法在构造位图的时候会尝试分配内存,也就是它们会导致原图直接加载到内存了,不满足我们的需求。我们可以通过 BitmapFactory.Options 设置一些附加的标记,指定解码选项,以此来解决该问题。如何操作呢?答案来了:将 inJustDecodeBounds 属性设置为 true,可以在解码时避免内存的分配,它会返回一个 null 的 Bitmap ,但是可以获取 outWidth、outHeight 和 outMimeType 值。利用该属性,我们就可以在图片不占用内存的情况下,在图片压缩之前获取图片的尺寸。怎样才能对图片进行压缩呢?通过设置BitmapFactory.Options中inSampleSize的值就可以实现。其计算方式大概就是:计算出实际宽高和目标宽高的比率,然后选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高。3.2.2 设置BitmapFactory.Options属性大概步骤如下所示要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。注意这个地方是核心,这个解析图片并没有生成bitmap对象(也就是说没有为它分配内存控件),而仅仅是拿到它的宽高等属性。然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。这一步会压缩图片。之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了。此时才正式创建了bitmap对象,由于前面已经对它压缩了,所以你会发现此时所占内存大小已经很少了。具体的实现代码public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { // 第一次解析将inJustDecodeBounds设置为true,来获取图片大小 final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options); // 调用上面定义的方法计算inSampleSize值 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 使用获取到的inSampleSize值再次解析图片 options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); }思考:inJustDecodeBounds这个参数是干什么的?如果设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数了。为何设置两次inJustDecodeBounds属性?第一次:设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数。第二次:将inJustDecodeBounds设置为false再次调用decode函数时就能生成bitmap了。而此时的bitmap已经压缩减小很多了,所以加载到内存中并不会导致OOM。3.3 图片质量压缩在Android中,对图片进行质量压缩,通常我们的实现方式如下所示://quality 为0~100,0表示最小体积,100表示最高质量,对应体积也是最大 bitmap.compress(Bitmap.CompressFormat.JPEG, quality , outputStream);在上述代码中,我们选择的压缩格式是CompressFormat.JPEG,除此之外还有两个选择:其一,CompressFormat.PNG,PNG格式是无损的,它无法再进行质量压缩,quality这个参数就没有作用了,会被忽略,所以最后图片保存成的文件大小不会有变化;其二,CompressFormat.WEBP,这个格式是google推出的图片格式,它会比JPEG更加省空间,经过实测大概可以优化30%左右。Android质量压缩逻辑,函数compress经过一连串的java层调用之后,最后来到了一个native函数:具体看:Bitmap.cpp,最后调用了函数encoder->encodeStream(…)编码保存本地。该函数是调用skia引擎来对图片进行编码压缩。3.4 双线性采样压缩双线性采样(Bilinear Resampling)在 Android 中的使用方式一般有两种:bm = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true); //或者直接使用 matrix 进行缩放 Matrix matrix = new Matrix(); matrix.setScale(0.5f, 0.5f); bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true);看源码可以知道createScaledBitmap函数最终也是使用第二种方式的matrix进行缩放双线性采样使用的是双线性內插值算法,这个算法不像邻近点插值算法一样,直接粗暴的选择一个像素,而是参考了源像素相应位置周围2x2个点的值,根据相对位置取对应的权重,经过计算之后得到目标图像。3.5 高清图分片加载适用场景 : 当一张图片非常大 , 在手机中只需要显示其中一部分内容 , BitmapRegionDecoder 非常有用 。主要作用 : BitmapRegionDecoder 可以从图像中 解码一个矩形区域 。相当于手在滑动的过程中,计算当前显示区域的图片绘制出来。基本使用流程 : 先创建,后解码 。调用 newInstance 方法 , 创建 BitmapRegionDecoder 对象 ;然后调用 decodeRegion 方法 , 获取指定 Rect 矩形区域的解码后的 Bitmap 对象3.6 图片综合压缩一般情况下图片综合压缩的整体思路如下:第一步进行采样率压缩;第二步进行宽高的等比例压缩(微信对原图和缩略图限制了最大长宽或者最小长宽);第三步就是对图片的质量进行压缩(一般75或者70);第四步就是采用webP的格式。关于图片压缩的综合案例如下具体可以参考:CompressServer04.色彩格式及内存优化4.1 RGB颜色种类RGB 色彩模式是工业界的一种颜色标准通过对红(R)、绿(G)、蓝(B)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的,RGB即是代表红、绿、蓝三个通道的颜色,这个标准几乎包括了人类视力所能感知的所有颜色,是运用最广的颜色系统之一。Android 中,像素的存储方式使用的色彩模式正是 RGB 色彩模式。4.2 ARGB色彩模式在 Android 中,我们常见的一些颜色设置,都是 RGB 色彩模式来描述像素颜色的,并且他们都带有透明度通道,也就是所谓的 ARGB。例如,我们常见的颜色定义如下://在代码中定义颜色值:蓝色 public final int blue=0xff0000ff; //或者在xml中定义: <drawable name="blue">#ff0000ff</drawable> 以上设置中,颜色值都是使用 16 进制的数字来表示的。以上颜色值都是带有透明度(透明通道)的颜色值,格式是 AARRGGBB,透明度、红色、绿色、蓝色四个颜色通道,各占有 2 位,也就是一个颜色通道,使用了 1 个字节来存储。4.3 改变色彩格式优化Android 中有多种 RGB 模式,我们可以设置不同的格式,来控制图片像素颜色的显示质量和存储空间。Android.graphics.Bitmap 类里有一个内部类 Bitmap.Config 类,它定义了可以在 Android 中使用的几种色彩格式:public enum Config { ALPHA_8 (1), RGB_565 (3), @Deprecated ARGB_4444 (4), ARGB_8888 (5), RGBA_F16 (6), HARDWARE (7); }解释一下这几个值分别代表了什么含义?我们已经知道了:A 代表透明度、R 代表红色、G 代表绿色、B 代表蓝色。ALPHA_8:表示,只存在 Alpha 通道,没有存储色彩值,只含有透明度,每个像素占用 1 个字节的空间。RGB_565:表示,R 占用 5 位二进制的位置,G 占用了6位,B 占用了 5 位。每个像素占用 2 个字节空间,并且不包含透明度。ARGB_4444:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 4 个 bit 位。每个像素占用 2 个字节空间。ARGB_8888:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 8 个 bit 位。每个像素占用 4 个字节空间。RGBA_F16:表示,每个像素存储在8个字节上。此配置特别适合广色域和HDR内容。HARDWARE:特殊配置,当位图仅存储在图形内存中时。 此配置中的位图始终是不可变的。那么开发中一般选择哪一种比较合适呢Android 中的图片在加载时,默认的色彩格式是 ARGB_8888,也就是每个像素占用 4 个字节空间,一张 2700 1900 像素的照片,加载到内存就需要 19.6M 内存空间(2592 1936 * 4 bytes)。如果图片在 UI 组件中显示时,不需要太高的图片质量,例如显示一张缩略图(不透明图片)等场景,这时,我们就没必要使用 ARGB_8888 的色彩格式了,只需要使用 RGB_565 模式即可满足显示的需要。那么,我们的优化操作就可以是:将 2700 1900 像素的原图,压缩到原图的低分辨率的缩略图 270 190 像素的图片,这时需要 0.2M 的内存。也就是从 19.6M内存,压缩为 0.2 M内存。我们还可以进一步优化色彩格式,由 ARGB_8888 改为 RGB_565 模式,这时,目标图片需要的内存就变为 270 190 2 = 0.1M 了。图片内存空间又减小了一倍。05.缓存的使用实践优化5.1 Lru内存缓存LruCache 类特别适合用来缓存 Bitmap,它使用一个强引用的 LinkedHashMap 保存最近引用的对象,并且在缓存超出设定大小时,删除最近最少使用的对象。给 LruCache 确定一个合适的缓存大小非常重要,我们需要考虑几个因素:应用剩余多少可用内存?需要有多少张图片同时显示到屏幕上?有多少图片需要准备好以便马上显示到屏幕?设备的屏幕大小和密度是多少?高密度的设备需要更大的缓存空间来缓存同样数量的图片。Bitmap 的尺寸配置是多少,花费多少内存?图片被访问的频率如何?如果其中一些比另外一些访问更频繁,那么我们可能希望在内存中保存那些最常访问的图片,或者根据访问频率给 Bitmap 分组,为不同的 Bitmap 组设置多个 LruCache 对象。是否可以在缓存图片的质量和数量之间寻找平衡点?有时,保存大量低质量的 Bitmap 会非常有用,加载更高质量的图片的任务可以交给另外一个后台线程处理。缓存太小会导致额外的花销却没有明显的好处,缓存太大同样会导致 java.lang.OutOfMemory 的异常,并且使得你的程序只留下小部分的内存用来工作(缓存占用太多内存,导致其他操作会因为内存不够而抛出异常)。所以,我们需要分析实际情况之后,提出一个合适的解决方案。LruCache是Android提供的一个缓存类,通常运用于内存缓存LruCache是一个泛型类,它的底层是用一个LinkedHashMap以强引用的方式存储外界的缓存对象来实现的。为什么使用LinkedHashMap来作为LruCache的存储,是因为LinkedHashMap有两种排序方式,一种是插入排序方式,一种是访问排序方式,默认情况下是以访问方式来存储缓存对象的;LruCache提供了get和put方法来完成缓存的获取和添加,当缓存满时,会将最近最少使用的对象移除掉,然后再添加新的缓存对象。如下源码所示,底层是LinkedHashMap。public LruCache(int maxSize) { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } this.maxSize = maxSize; this.map = new LinkedHashMap<K, V>(0, 0.75f, true); }在使用LruCache的时候,首先需要获取当前设备的内存容量,通常情况下会将总容量的八分之一作为LruCache的容量,然后重写LruCache的sizeof方法,sizeof方法用于计算缓存对象的大小,单位需要与分配的容量的单位一致;// 获取系统最大缓存 int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // set LruCache size; // 使用最大可用内存值的1/8作为缓存的大小 int cacheSize = maxMemory / 8; LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) { @Override protected int sizeOf(@NonNull String uri, @NonNull Bitmap bitmap) { // 重写此方法来衡量每张图片的大小,默认返回图片数量 return bitmap.getRowBytes() * bitmap.getHeight() / 1024; } }; //插入对象 memoryCache.put(key, bitmap); //取出对象 memoryCache.get(key);如何淘汰缓存这个就要看LinkedHashMap集合的特点呢!LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。在进行淘汰时,移除遍历器前端的元素,直至缓存总大小降低到指定大小以下。5.2 Lru内存注意事项看一个真实的场景假设我们的LruCache可以缓存80张,每次刷新从网络获取20张图片且不重复,那么在刷新第五次的时候,根据LruCache缓存的规则,第一次刷新的20张图片就会从LruCache中移出,处于等待被系统GC的状态。如果我们继续刷新n次,等待被回收的张数就会累积到 20 * n 张。会出现什么问题会出现大量的Bitmap内存碎片,我们不知道系统什么时候会触发GC回收掉这些无用的Bitmap,对于内存是否会溢出,是否会频繁GC导致卡顿等未知问题。解决方案该怎么做?第一种:在3.0以后引入了 BitmapFactory.Options.inBitmap,如果设置此项,需要解码的图片就会尝试使用该Bitmap的内存,这样取消了内存的动态分配,提高了性能,节省了内存。第二种:把处于无用的状态的Bitmap放入SoftReference。SoftReference引用的对象会在内存溢出之前被回收。关于Lru缓存案例和代码可以参考:AppLruCache5.3 使用Lru磁盘缓存内存缓存能够提高访问最近用过的 Bitmap 的速度,但是我们无法保证最近访问过的 Bitmap 都能够保存在缓存中。像类似 GridView 等需要大量数据填充的控件很容易就会用尽整个内存缓存。另外,我们的应用可能会被类似打电话等行为而暂停并退到后台,因为后台应用可能会被杀死,那么内存缓存就会被销毁,里面的 Bitmap 也就不存在了。一旦用户恢复应用的状态,那么应用就需要重新处理那些图片。磁盘缓存可以用来保存那些已经处理过的 Bitmap,它还可以减少那些不再内存缓存中的 Bitmap 的加载次数。当然从磁盘读取图片会比从内存要慢,而且由于磁盘读取操作时间是不可预期的,读取操作需要在后台线程中处理。注意:如果图片会被更频繁的访问,使用 ContentProvider 或许会更加合适,比如在图库应用中。注意:因为初始化磁盘缓存涉及到 I/O 操作,所以它不应该在主线程中进行。但是这也意味着在初始化完成之前缓存可以被访问。为了解决这个问题,在上面的实现中,有一个锁对象(lock object)来确保在磁盘缓存完成初始化之前,应用无法对它进行读取。内存缓存的检查是可以在 UI 线程中进行的,磁盘缓存的检查需要在后台线程中处理。磁盘操作永远都不应该在 UI 线程中发生。当图片处理完成后,Bitmap 需要添加到内存缓存与磁盘缓存中,方便之后的使用。06.不同版本对Bitmap管理6.1 演变进程Android 2.3.3 (API level 10)以及之前,一个 Bitmap 的像素数据是存放在 Native 内存空间中的。这些数据与 Bitmap 对象本身是隔离的,Bitmap 本身被存放在 Dalvik 堆中。并且无法预测在 Native 内存中的像素级数据何时会被释放,这意味着程序容易超过它的内存限制并且崩溃。Android 3.0 (API Level 11)开始像素数据则是与 Bitmap 本身一起存放在 Dalvik 堆中。Android 8.0(Android O)及之后的版本中Bitmap 的像素数据的内存分配又回到了 Native 层,它是在 Native 堆空间进行分配的。6.2 管理Bitmap内存管理 Android 2.3.3 及以下版本的内存使用在 Android 2.3.3 (API level 10) 以及更低版本上,推荐使用 recycle() 方法。 如果在应用中显示了大量的 Bitmap 数据,我们很可能会遇到 OutOfMemoryError 的错误。 recycle() 方法可以使得程序更快的释放内存。管理 Android 3.0 及其以上版本的内存从 Android 3.0 (API Level 11)开始,引进了 BitmapFactory.Options.inBitmap 字段。 如果使用了这个设置字段,decode 方法会在加载 Bitmap 数据的时候去重用已经存在的 Bitmap。这意味着 Bitmap 的内存是被重新利用的,这样可以提升性能,并且减少了内存的分配与回收。然而,使用 inBitmap 有一些限制,特别是在Android 4.4 (API level 19)之前,只有同等大小的位图才可以被重用。管理 Android 8.0 及其以上版本的内存在 Android 8.0 及其以上版本,处理内存,也遵循 Android 3.0 以上版本同样的方式。同时,图片像素数据存储在 native 层,并且不占用 Java 堆的空间,这也代表着我们拥有更大的图片存储空间,可以加载质量更高、数据更多的图片到内存中。但是,内存依然不是无限的,应用还是要受到手机内存的限制,所以一定要注意这一点。6.3 提高Bitmap复用Android3.0之后,并没有强调Bitmap.recycle();而是强调Bitmap的复用。使用LruCache对Bitmap进行缓存,当再次使用到这个Bitmap的时候直接获取,而不用重走编码流程。Android3.0(API 11之后)引入了BitmapFactory.Options.inBitmap字段,设置此字段之后解码方法会尝试复用一张存在的Bitmap。这意味着Bitmap的内存被复用,避免了内存的回收及申请过程,显然性能表现更佳。使用这个字段有几点限制:声明可被复用的Bitmap必须设置inMutable为true;Android4.4(API 19)之前只有格式为jpg、png,同等宽高(要求苛刻),inSampleSize为1的Bitmap才可以复用;Android4.4(API 19)之前被复用的Bitmap的inPreferredConfig会覆盖待分配内存的Bitmap设置的inPreferredConfig;Android4.4(API 19)之后被复用的Bitmap的内存必须大于需要申请内存的Bitmap的内存;Android4.4(API 19)之前待加载Bitmap的Options.inSampleSize必须明确指定为1。Bitmap复用的实验,代码如下所示,然后看打印的日志信息从内存地址的打印可以看出,两个对象其实是一个对象,Bitmap复用成功;bitmapReuse占用的内存(4346880)正好是bitmap占用内存(1228800)的四分之一;getByteCount()获取到的是当前图片应当所占内存大小,getAllocationByteCount()获取到的是被复用Bitmap真实占用内存大小。虽然bitmapReuse的内存只有4346880,但是因为是复用的bitmap的内存,因而其真实占用的内存大小是被复用的bitmap的内存大小(1228800)。这也是getAllocationByteCount()可能比getByteCount()大的原因。@RequiresApi(api = Build.VERSION_CODES.KITKAT) private void initBitmap() { BitmapFactory.Options options = new BitmapFactory.Options(); // 图片复用,这个属性必须设置; options.inMutable = true; // 手动设置缩放比例,使其取整数,方便计算、观察数据; options.inDensity = 320; options.inTargetDensity = 320; Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bg_autumn_tree_min, options); // 对象内存地址; Log.i("ycBitmap", "bitmap = " + bitmap); Log.i("ycBitmap", "ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount()); // 使用inBitmap属性,这个属性必须设置; options.inBitmap = bitmap; options.inDensity = 320; // 设置缩放宽高为原始宽高一半; options.inTargetDensity = 160; options.inMutable = true; Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.bg_kites_min, options); // 复用对象的内存地址; Log.i("ycBitmap", "bitmapReuse = " + bitmapReuse); Log.i("ycBitmap", "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount()); Log.i("ycBitmap", "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount()); //11-26 18:24:07.971 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap = android.graphics.Bitmap@9739bff //11-26 18:24:07.972 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 4346880:::bitmap:AllocationByteCount = 4346880 //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse = android.graphics.Bitmap@9739bff //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 1228800:::bitmap:AllocationByteCount = 4346880 //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse:ByteCount = 1228800:::bitmapReuse:AllocationByteCount = 4346880 }07.图片其他方面优化7.1 减少PNG图片的使用这里要介绍一种新的图片格式:Webp,它是由 Google 推出的一种既保留 png 格式的优点,又能够减少图片大小的一种新型图片格式。在 Android 4.0(API level 14) 中支持有损的 WebP 图像,在 Android 4.3(API level 18) 和更高版本中支持无损和透明的 WebP 图像。注意一下,Webp格式图片仅仅只是减少图片的质量大小,并不会减少加载图片后的内存占用。7.2 切割圆角优化方案1:直接采用Canvas.clipPath 相关api,裁剪出一个圆角区域。该方案简单暴力,通用性强。如果只是一个静态的单图视图,该方法问题不大,但如果是复杂页面,滚动的时候,测试就会跟你说,页面卡顿了,要优化。原因就是 Canvas.clip的相关api损耗相对较大。方案2:系统提供的CardView设置圆角把原来全工程各个视频控件和图片控件的外层,都加上一层CardView。改造成本大,布局层级更深一层,layout时间加长。方案3:使用setXfermode法此种方式就是再new一个相同尺寸的bitmap,然后使用paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));先画圆角矩形,再画原始bitmap,然后就得到了一个圆角的bitmap。早期用得较多,占用bitmap双倍内存。方案4:图片加载库比如Glide,Fresco等在底层,无非也是使用上面的这两种种方式。早期的使用setXfermode来实现,后来使用BitmapShader实现。使用简单,稳定。方案5:遮罩还是使用setXfermode,不过与方法一不同的是:不对图片作任何更改,只在圆角之外再画一层与背景颜色相同的四个角来遮挡,在视觉上造成圆角图片的效果。那个切割圆角该怎么优化呢?使用方案3,可以采用自定义view,支持LinearLayout、RelativeLayout、FrameLayout、ConstraintLayout、ImageView、TextView、View、Button等设置圆角。具体案例可见:RoundCorners7.3 如何给图片置灰色大概的操作步骤。具体可以参考:PicCalculateUtils第一步:获取原始图片的宽高,然后创建一个bitmap可变位图对象。第二步:创建画板canvas对象,然后创建画笔paint。然后调用canvas.drawBitmap方法绘制图片第三步:对画笔进行修饰,设置画笔颜色属性,这里使用到了ColorMatrix,核心就是设置饱和度为0,即可绘制灰色内容7.4 如何处理图片旋转呢在Android中使用ImageView显示图片的时候发现图片显示不正,方向偏了或者倒过来了。解决这个问题很自然想到的两步走,首先是要自动识别图像方向,计算旋转角度,然后对图像进行旋转并显示。识别图像方向首先在这里提一个概念EXIF(Exchangeable Image File Format,可交换图像文件)。简而言之,Exif是一个标准,用于电子照相机(也包括手机、扫描器等)上,用来规范图片、声音、视屏以及它们的一些辅助标记格式。Exif支持的格式如下:图像;压缩图像文件:JPEG、DCT;非压缩图像文件:TIFF;音频;RIFF、WAVAndroid提供了对JPEG格式图像Exif接口支持,可以读取JPEG文件metadata信息,参见ExifInterface。这些Metadata信息总的来说大致分为三类:日期时间、空间信息(经纬度、高度)、Camera信息(孔径、焦距、旋转角、曝光量等等)。关于图像旋转获取了图片的旋转方向后,然后再设置图像旋转。最后Bitmap提供的静态createBitmap方法,可以对图片设置旋转角度。具体看:PicCalculateUtils7.5 保存图片且刷相册大概的操作步骤如下所示。具体可看:ImageSaveUtils第一步:创建图片文件,然后将bitmap对象写到图片文件中第二步:通过MediaStore将图片插入到共享目录相册中第三步:发送通知,通知相册中刷新插入图片的数据。注意,获取图片资源uri刷新即可,避免刷新所有数据造成等待时间过长。7.6 统一图片域名优化域名统一减少了10%+的重复图片下载和内存消耗。同时减少之前多域名图片加载时重复创建HTTPS请求的过程,减少图片加载时间。7.7 优化H5图片加载通过拦截WebView图片加载的方式,让原生图片库来下载图片之后传递图片二进制数据给WebView显示。采用OkHttp拦截资源缓存,下面是大概的思路。缓存的入口从shouldInterceptRequest出发第一步,拿到WebResourceRequest对象中请求资源的url还有header,如果开发者设置不缓存则返回null第二步,如果缓存,通过url判断拦截资源的条件,过滤非http,音视频等资源,这个是可自由配置缓存内容比如css,png,jpg,xml,txt等第三步,判断本地是否有OkHttp缓存数据,如果有则直接读取本地资源,通过url找到对应的path路径,然后读取文件流,组装数据返回。第四步,如果没有缓存数据,创建OkHttp的Request请求,将资源网络请求交给okHttp来处理,并且用它自带的缓存功能,当然如果是请求失败或者异常则返回null,否则返回正常数据关于webView图片缓存的方案,可以直接参考:YCWebView7.8 优化图片阴影效果阴影效果有哪些实现方式第一种:使用CardView,但是不能设置阴影颜色第二种:采用shape叠加,存在后期UI效果不便优化第三种:UI切图第四种:自定义View第五种:自定义Drawable否定上面前两种方案原因分析?第一个方案的CardView渐变色和阴影效果很难控制,只能支持线性或者环装形式渐变,这种不满足需要,因为阴影本身是一个四周一层很淡的颜色包围,在一个矩形框的层面上颜色大概一致,而且这个CardView有很多局限性,比如不能修改阴影的颜色,不能修改阴影的深浅。所以这个思路无法实现这个需求。第二个采用shape叠加,可以实现阴影效果,但是影响UI,且阴影部分是占像素的,而且不灵活。第三个方案询问了一下ui。他们给出的结果是如果使用切图的话那标注的话很难标,身为一个优秀的设计师大多对像素点都和敏感,界面上的像素点有一点不协调那都是无法容忍的。网上一些介绍阴影效果方案所有在深奥的技术,也都是为需求做准备的。也就是需要实践并且可以用到实际开发中,这篇文章不再抽象介绍阴影效果原理,理解三维空间中如何处理偏移光线达到阴影视差等,网上看了一些文章也没看明白或者理解。这篇博客直接通过调用api实现预期的效果。多个drawable叠加,使用layer-list可以将多个drawable按照顺序层叠在一起显示,默认情况下,所有的item中的drawable都会自动根据它附上view的大小而进行缩放,layer-list中的item是按照顺序从下往上叠加的,即先定义的item在下面,后面的依次往上面叠放阴影是否占位使用CardView阴影不占位,不能设置阴影颜色和效果使用shape阴影是可以设置阴影颜色,但是是占位的几种方案优缺点对比分析CardView 优点:自带功能实现简单 缺点:自带圆角不一定可适配所有需求layer(shape叠加) 优点:实现形式简单 缺点:效果一般自定义实现 优点:实现效果好可配置能力高 缺点:需要开发者自行开发关于解决阴影效果具体各种方案的对比可以参考这个demo:AppShadowLib7.9 图片资源的压缩我们应用中使用的图片,设计师出的原图通常都非常大,他们通常会使用工具,经过一定的压缩,缩减到比较小一些的大小。但是,这些图片通常都有一定的可压缩空间,我在之前的项目中,对图片进行了二次压缩,整体压缩率达到了 40%~50% ,效果还是非常不错的。这里介绍下常用的,图片压缩的方法:使用压缩工具对图片进行二次压缩。根据最终图片是否需要透明度展示,优先选择不透明的图片格式,例如,我们应该避免使用 png 格式的图片。对于色彩简单,例如,一些背景之类的图片,可以选择使用布局文件来定义(矢量图),这样就会非常节省内存了。如果包含透明度,优先使用 WebP 等格式图像。图片在上线前进行压缩处理,不但可以减少内存的使用,如果图片是网络获取的,也可以减少网络加载的流量和时间。推荐一个图片压缩网站:tinypng网站
隐私合规综合实践目录介绍01.整体概述介绍1.1 遇到问题说明1.2 项目背景1.3 设计目标1.4 产生收益分析02.隐私合规测什么2.1 隐私合规是什么2.2 为何做隐私合规2.3 隐私合规政策案例2.4 为何做权限合规04.隐私合规检测4.1 违规收集个人信息4.2 超范围收集个人信息4.3 违规使用个人信息4.4 过度索取权限4.5 自启动和关联启动05.隐私合规实践5.1 整体合规思路5.2 工具检测隐私API5.3 工具检测权限5.4 敏感信息控频5.5 隐私协议声明5.6 敏感权限实践5.7 底层依赖库权限说明06.合规测试检查重点6.1 合规处理优先级6.2 QA测试检查重点6.3 交互层面合规6.4 服务端敏感收集6.5 隐私协议筛查07.其他说明介绍7.1 参考博客链接7.2 相关demo链接01.整体概述介绍1.1 遇到问题说明国内对应用程序安全隐私问题监管变的越来越严格。各个应用市场对APP上架也有比较严格的检查。出现隐私合规安全问题主要有哪些呢?在用户同意隐私协议之前,不能有收集用户隐私数据的行为。例如,在用户同意之前不能去获取 Android ID、Device ID、MAC 等隐私数据在用户同意隐私协议之后,搜集用户隐私数据的行为不能超出实现服务场景所必需的最低频率。例如,某些应用会在每次网络请求时将当前设备的 Android ID 作为 header 一起上报,如果没有对 Android ID 进行缓存处理的话,搜集该数据的行为频率就会非常高,此时一样存在隐私合规问题。工信部隐私合规说明具体可以看一下这篇文档:工信部隐私合规文档1.2 项目背景最关键的问题是用户同意隐私协议之前,不能有收集用户隐私信息的行为,例如获取deviceId、androidId等信息,除此之外,对于频繁申请权限、超范围申请权限也是需要注意的。除了开迭代针对性整改,从技术角度思考,有没有一劳永逸的办法,杜绝隐私调用不合规问题呢?1.3 设计目标针对提前收集用户隐私数据。需要统计出整个项目中所有涉及到隐私行为的相关代码,根据业务流程来判断该隐私行为是否合理、以及是否会在用户同意隐私协议之前被触发。这就需要对整个项目进行静态扫描。针对收集隐私数据哪里调用。需要在应用运行时动态记录每次触发隐私行为的时间点和调用链信息,根据触发时间来判断该隐私行为是否过量执行,根据调用链信息来辅助判断具体是哪一块业务在获取隐私数据。这就需要对应用进行动态记录。1.4 产生收益分析编码排查耗时大如果单纯靠开发人员来肉眼识别代码和编码统计的话,工作量非常大而且也很不现实,因为一个大型项目往往都会引入多个依赖库和第三方 SDK,可以规范自有代码,但没法修改和有效约束外部依赖,也很难理清楚依赖库的内部逻辑和调用链关系。提高合规隐私检测效率当检测有调用隐私数据时,在控制台打印输出提示,给出堆栈信息让开发快速定位调用链路;当检测到隐私行为后,输出相对应的记录报告,以便开发人员能够在开发阶段排查问题。02.隐私合规测什么2.1 隐私合规是什么对客户端而言,权限隐私可分为 权限 和 隐私 两个大的方面。权限为用户通过app内弹窗设置或者手机设置内对应app的权限设置方式给予对应app相应的权限如电话权限,定位权限,相机权限,浮窗权限,读写权限等。在每个申请危险权限前,都需要弹窗说明权限解释说明。隐私为app使用过程中与用户个人相关的个人信息如所在位置,Mac地址,设备id等。就Android端而言,多数隐私信息需要对应授权后才能获取,但目前仍存在部分隐私信息无需授权就可以拿到的。2.2 为何做隐私合规大众隐私意识觉醒,权限隐私安全性差会直接导致用户不愿使用;日趋严格的权限治理和隐私安全治理,工信部和市场的严格管控;客户端作为与用户最直接的交互信息收集入口,有义务合规化的收集和使用用户信息。2.3 隐私合规政策案例隐私合规案例比如获取设备信息:获取设备id,sim,androidId等;比如获取危险权限信息:获取读写存储卡权限,获取电话权限等。需要有文案描述收集设备id,为了帮助开发者在进行消息推送时识别最终用户设备,保障开发者及最终用户正常使用消息推送服务,提升消息推送服务的效率以及准确率。获取读写权限,帮助开发者进行最终用户设备识别,保障开发者及最终用户正常使用消息推送服务,提升消息推送服务的效率以及准确率;更准确定位并解决产品和服务使用问题,改进及优化产品和服务体验。2.4 为何做权限合规首先权限合规有两大点第一点:那里使用到了权限就在那里申请;第二点:使用权限的时候需要弹窗说明该权限的用途。列举一下我实践案例中的权限合规梳理04.隐私合规检测4.1 违规收集个人信息场景说明:未经用户同意,存在收集IMEI、设备id,设备MAC地址和软件安装列表、通讯录和短信的行为。整改建议:隐私政策隐私弹窗必须使用明确的“同意\拒绝”按钮;只有当用户点击“同意”后,APP和SDK才能调用系统接口和读取收集用户的信息。客户端如何做?①用户在点击隐私政策协议“同意”按钮前,APP和SDK不能调用系统的敏感权限接口,特别是能获取IMEI、IMSI、MAC、IP、Android、已安装应用列表、硬件序列表、手机号码、位置等等信息的系统接口。②集成的第三方SDK建议升级到最新版本,用户在点击隐私政策协议“同意”按钮后,SDK再进行初始化。③技术同学可在“同意”按钮上加入判定函数,当用户点击“同意”后,APP和SDK再执行调用系统接口的相关函数行为。4.2 超范围收集个人信息场景说明:1.APP未见向用户告知且未经用户同意,在某功能中,存在收集通讯录、短信、通话记录、相机等信息的行为,非服务所必需且无合理应用场景,超出与收集个人信息时所声称的目的具有直接或合理关联的范围。2.未经用户同意,SDK存在按照一定频次读取位置信息等个人信息行为,非服务所必需且无合理应用场景,超出实现产品或服务的业务功能所必需的最低频率。整改建议:针对1,当用户点击“同意”后,APP和SDK再执行调用系统接口的相关函数行为。然后APP隐私政策内需要补充收集(运行中的进程、【广点通SDK】收集IMSI)信息的规则说明。针对2,App或者Sdk收集用户信息频率超过合规范围,尽可能保证全局只收集1次(最多不超过3次),收集频次不要超过1次/秒。客户端如何做?举个例子,App在同意隐私政策前,不会收集android_id。在同意隐私政策后,有采集行为,但是4分钟采集了12次,则会认为过于频繁。App修复该问题,可以统一管理敏感信息采集入口,缓存敏感信息数据,可以设定缓存过期时间(建议设置超过5分钟)。获取android_id,缓存下来,下次调用先拿缓存,避免频繁调用系统api。4.3 违规使用个人信息场景说明:1.APP未见向用户告知且未经用户同意,存在将IMEI/设备MAC地址/软件安装列表等个人信息发送给友盟/极光/个推等第三方SDK的行为。2.APP未见向用户明示分享的第三方名称、目的及个人信息类型,用户同意隐私政策后,存在将IMEI/设备MAC地址/软件安装列表等个人信息发送给友盟/极光/个推等第三方SDK的行为。整改建议:针对1,APP和集成的SDK在用户“同意”隐私政策,然后在调用初始化sdk操作。针对2,在隐私政策中补充第三方SDK收集(比如声网sdk收集IMSI)信息的规则说明。敏感个人信息范围参考《信息安全技术个人信息安全规范》4.4 过度索取权限场景说明:1.APP首次启动时,向用户索取电话、通讯录、定位、短信、录音、相机、存储、日历等权限,用户拒绝授权后,应用退出或关闭(应用陷入弹窗循环,无法正常使用)。2.向用户索取危险权限(电话、通讯录、定位、短信、录音、相机、存储、日历等权限),没有添加申请权限目的告知用户。3.APP运行时,向用户频繁弹窗申请开启与当前服务场景无关的权限,影响用户正常使用。4.未见使用权限对应的相关产品或服务时,提前向用户弹窗申请开启通讯录/定位/短信/录音/相机/XXX等权限。整改建议:针对1场景,举例说明APP向用户索取(电话)权限,用户拒绝后,APP不能退出或关闭,必须保证APP可以继续正常运行。针对2场景,APP需要先通过弹窗向用户说明申请(电话)权限的目的,用户同意后再申请权限。用户拒绝后,APP不能退出或关闭,必须保证APP可以继续正常运行。针对3场景,APP向用户索取(电话)权限,用户拒绝后,APP不能重复向用户申请权限。(权限申请弹窗的“禁止后不再询问”是系统提供的功能,属于管理功能,不是APP自身机制,APP要能做到拒绝后不再触发申请权限弹窗)。针对4场景,比如需要用到电话的时候,先通过弹窗向用户说明申请(电话)权限的目的,用户同意后再申请权限。不要在没有使用到电话功能页面,去申请电话权限。4.5 自启动和关联启动场景说明:1.APP未向用户明示未经用户同意,且无合理的使用场景,存在频繁自启动或关联启动的行为。2.APP虽然有向用户明示并经用户同意环节,但频繁自启动或关联启动发生在用户同意前。3.APP非服务所必需或无合理应用场景,超范围频繁自启动或关联启动第三方APP。整改建议:针对1,2,3场景。建议删除相关自启动函数代码。如APP必须使用(自启动)能力,请在隐私政策协议中清楚说明自启动的规则说明,并且取得用户同意后执行。客户端如何做?App没有自启动场景和服务,则删除相关自启动的函数调用代码。App有自启动场景和服务,则在隐私政策中做好完整规则说明,在用户同意隐私政策前不要执行自启动代码,在同意隐私后才可以执行自启动代码。4.5 隐私数据注意项遇到的问题,每次排查隐私数据很麻烦因为随着项目更迭,随时可能有新的隐私安全问题被引入进来,而如果每次发版前都要重新走一遍上述流程来排查是否存在问题的话,也是很麻烦。如何保证隐私合规绝对安全呢一般都是会通过一个标记位来记录用户是否已经同意过隐私协议,我们可以在每次获取敏感数据前均先判断该标记位,如果用户还未同意隐私协议的话就直接返回空数据,否则才去真正执行操作。05.隐私合规检测库实践5.1 整体合规思路开发了一个针对 Android APK 的敏感方法调用的静态检查工具。检查关键字,对于一些敏感 API 调用,例如 oaid、androidId、imei 相关的调用。其实只要能检测到这些相关 API 里的一些关键字,找出整个 APP 里面有哪些地方直接调用了这些方法就可以了。针对的上述的一些场景,这个工具具有两个方向的工作:APK 包的扫描,检查出整个APK中,哪些地方有对包含上面这些 API 关键字的直接调用。运行时检查。针对运行时频繁调用这个场景,还是需要在运行时辅助检查特定API的调用情况。5.2 工具检测隐私API方案1:Xposed如果你对Xposed比较熟悉,并且手头有个root的设备安装了Xposed框架,那么直接开发一个Xposed模块来hook指定方法就可以了。缺点是需要root权限……方案2:VirtualXposedVirtualXposed 是基于VirtualApp 和 epic 在非ROOT环境下运行Xposed模块的实现(支持5.0~10.0)。VirtualXposed其实就是一个支持Xposed的虚拟机,我们把开发好的Xposed模块和对应需要hook的App安装上去就能实现hook功能。方案3:epic如果不想折腾 Xposed 或者 VirtualXposed,只要在应用内接入epic,就可以实现应用内Xposed hook功能,满足运行hook需求。epic 存在兼容性问题,例如Android 11 只支持64位App,所以建议只在debug环境使用。最终选择:方案3它可以拦截本进程内部几乎任意的 Java 方法调用,可用于实现 AOP 编程、运行时插桩、性能分析、安全审计等。使用起来也非常简单:提前设置需要 hook 哪个 java 方,比如,我要 hook TelephonyManager 的 getDeviceId 方法://核心方法 DexposedBridge.findAndHookMethod(TelephonyManager.class, "getDeviceId", new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { super.beforeHookedMethod(param); String className = param.method.getDeclaringClass().getName(); String methodName = param.method.getName(); Log.i(PrivacyHelper.TAG, "检测到风险函数被调用: " + className + "#" + methodName); Log.d(PrivacyHelper.TAG, StackTraceUtils.getMethodStack()); } @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { super.afterHookedMethod(param); Log.d(PrivacyHelper.TAG, "afterHookedMethod getDeviceId"); } });在代码中如果有地方调用 TelephonyManager.getDeviceId 的,都会被 epic 的 beforeHookedMethod 给拦截到,只需要在 beforeHookedMethod 打印出堆栈即可看到是谁调用的。为何打印堆栈信息在应用运行时记录每次触发隐私行为的时间点和调用链信息,根据触发时间来判断该隐私行为是否过量执行,根据调用链信息来辅助判断具体是哪一块业务需要来获取隐私数据。当检测到了风险函数调用情况,则需要知道该函数是在哪里调用的?这个该怎么做呢?获取当前线程,然后通过线程获取stackTraces,再然后遍历打印即可。根据堆栈信息,可以看到调用链的类名,方法名称,代码行数等。关于针对运行期检查隐私合规api调用具体可以看:MonitorPrivacy5.3 工具检测权限使用场景新增三方sdk或开源库,有收集个人信息或者权限的,这个时候需要用工具检测。主要是为了新增的权限在隐私政策中申请,用工具去保证重要权限防止漏掉隐私说明。敏感权限检查扫描AndroidManifest.xml检查敏感权限,这个可以借助三方工具。5.4 敏感信息控频敏感设备信息获取是指只要调用系统API就会认为获取敏感信息,并不关心有没有获取到敏感信息以及调用系统API的目的。主要原则:没有权限不能调用系统API,如无READ_PHONE_STATE权限调用掉用getImei()、getDeviceId()等API控制调用频次,即使有权限不能频繁调用系统API控制传输频次,不能频繁传输敏感信息控频方案:App自身系统隐私API调用,统一调用基础库,该库统一做内存级别缓存,不重复调用系统API。传输控频,主要有2种方案:敏感信息统一传送一次,各业务单独对接,业务见相互依赖强;数据统一整包加密敏感信息主要有那些imei(IMEI),android_id(Android唯一标识符),provider_name(手机运营商),operator_id(卡运营商id),sn(sn设备号)等等举一个简单例子【利用缓存,避免频繁调用api获取敏感信息】public static String getAndroidId(@NonNull Context context, String defaultValue) { if (null != Cache.ANDROID_ID) { return Cache.ANDROID_ID; } String androidId = SafePrivateHelper.getAndroidId(); if (!TextUtils.isEmpty(androidId)) { Cache.ANDROID_ID = androidId; } else { androidId = defaultValue; } return androidId; }具体的方案可以看这个依赖库:PrivateServer5.5 隐私协议声明隐私协议声明很重要,能规避大部分问题,隐私协议声明要注意几点:app自身收集的个人信息、用途需要在隐私协议中声明app申请权限及目的在隐私协议中声明集成的所有第三方sdk及第三方sdk收集个人信息的用户需要在隐私协议中声明;包括检测机构检测出来的+三方sdk隐私协议中声明的在隐私协议中声明,app及三方sdk在静默和后台也会收集个人信息针对危险权限,需要在隐私协议中说明一下。具体如下所示:5.6 敏感权限实践敏感权限。如下所示,频繁读取敏感权限也会触发合规android.permission.READ_CALL_LOG:读取通话记录 android.permission.WRITE_CALL_LOG:写通话记录 android.permission.READ_CONTACTS:读取联系人 android.permission.WRITE_CONTACTS:写联系人 android.permission.ACCESS_FINE_LOCATION:精确定位 android.permission.RECEIVE_BOOT_COMPLETED:开机自启动 android.permission.SEND_SMS: 发送短信 android.permission.RECEIVE_SMS:接受收短信 android.permission.READ_SMS:读取短信5.7 底层依赖库权限说明针对申请隐私权限需要弹窗说明比如以前可以在app启动的时候,一下子申请完所有权限。但是这种已经不符合规范了,要求必须在使用的时候申请权限,并且要有弹窗说明。一句话概括为那里使用那里申请权限!举一个例子加深理解比如你有一个二维码扫描库,你在扫描的时候需要申请相机权限,并且先要弹出弹窗说明文案;比如你有个相册库,你在打开相册的时候,需要申请读写权限。二维码库和相册库已经自己申请权限,如何复用壳工程中的权限说明弹窗?具体方案:采用接口隔离,具体的实现类放到壳工程中实现。具体可以看看我的这个基础空间通用接口库:EventUploadLib06.合规测试检查重点6.1 合规处理优先级合规需求第一优先级,第一时间跟版上线,不要有任何商量和侥幸,比开发需求还要重要!否则应用市场无法上架很麻烦……新增需求不合规不允许上线:新增需求如有不合规的地方,但又来不及修改,则延期上线,整改到合规再上发版准出增加,合规确认环节:每次发版,产品、研发、测试 都需要负责检查对应的合规项,对检查结果负责,都确认之后才可以发版6.2 QA测试检查重点重点手工 check list。研发和测试都要重视这块工作!第一次打开时,各种隐私协议打开是否正常。第一次打开时,未同意隐私协议前,不能有任何网络请求发出,可通过手机设置代理查看。第一次打开时,未同意隐私协议前,不能有任何隐私 API 调用,通过Xposed的手机是否有隐私api调用。6.3 交互层面合规申请权限弹窗申明App上一些用户权限需要有申请弹窗说明,相关交互内容有特定的交互要求,需qa配合在发版前回归阶段进行有限的检查。筛查范围安卓端,app启动时,明显的权限申请弹窗、隐私协议、个性化推荐等交互流程。权限弹窗控制频次(比如App申请通知权限弹窗设置用于点击取消后,频次至少间隔48小时);同意隐私协议不能默认勾选;个性化推荐支持关闭权限弹窗控制频次操作步骤:最新下载未打开的安卓包,启动app时,出现权限弹框任意一个例如:本地存储、相机、定位权限,点击拒绝;将app关闭杀死后台程序,再次打开app,查看是否还有上述被拒绝的权限弹框,例如:本地存储、相机、定位权限。预期效果:如果拒绝之后再弹框就是有问题、不合理,需要上报开发排查原因;如果没有上述三个权限弹窗,则为正常。同意隐私协议不能默认勾选打开app时,关注涉及隐私协议页面,查看默认勾选状态。预期效果:默认为:未勾选,则为正常;默认为:勾选,则为有问题,需要上报开发排查原因。收集与功能无关的个人信息在未使用任何功能的情况,查看是否有弹窗索取手机存储权限。预期效果:不进行弹窗索取手机存储权限。6.4 服务端敏感收集背景简单说明一下禁止APP收集用户信息,比如imei、cuid,oaid等用户设备信息。所以在发版前需要确保客户端内请求不携带imei、oaid等敏感字段,接口返回也不包含以上敏感字段。筛查范围记录APP内客户端/fe发起的接口请求,建议各APP先彻底筛查一遍,排除隐患,后续迭代版本例行筛查F0功能或新增功能即可。筛查规则:请求 request body/response中不包括imei,imei的值(一般有两种形式,eg:imei正常格式没有加密的 imei=866917034628451,加密过的imei = oC0JwJIxaEiKIWlzkqHO)。筛查方法说明连接代理(全局代理,不是指定某个域名的代理),以charles为例。回到charles界面,ctrl+F,输入关键字“imei”“oaid”以及imei的值进行反查。关注request里面是否携带此三个参数,只要有携带,不管是否传值,都是问题,需要报给客户端;关注response中是否返回此三个参数,如果有,需要上报开发排查原因,是否可以不返回此参数。6.5 隐私协议筛查方案说明:确保隐私协议可访问; 通过脚本自动检查三方 SDK 是否在隐私协议中声明;法务 + 产品 定期检查;实施措施:建立隐私协议可访问性自动化巡检机制;三方SDK检测,根据检测出来新增的三方SDK扫描隐私协议,确认该sdk是否在隐私协议中声明。开发需要注意点新增三方sdk或开源库,有收集个人信息或者权限的 必须在隐私政策里申明(找产品和法务);没收集个人信息或者权限的三方库或者自有库,确定好后将sdk包名和中文名称备注一下。07.其他说明介绍7.1 参考博客链接腾讯隐私政策审核规范https://wikinew.open.qq.com/index.html#/iwiki/875339652腾讯隐私政策整改方法https://wikinew.open.qq.com/index.html#/iwiki/8861441667.2 相关demo链接隐私合规demo
创建型:单例设计模式1目录介绍01.单例模式介绍02.单例模式定义03.单例使用场景04.思考几个问题05.为什么要使用单例06.处理资源访问冲突07.表示全局唯一类01.单例模式介绍单例模式是应用最广的模式也是最先知道的一种设计模式,在深入了解单例模式之前,每当遇到如:getInstance()这样的创建实例的代码时,我都会把它当做一种单例模式的实现。单例模式特点构造函数不对外开放,一般为private通过一个静态方法或者枚举返回单例类对象确保单例类的对象有且只有一个,尤其是在多线程的环境下确保单例类对象在反序列化时不会重新构造对象02.单例模式定义保证一个类仅有一个实例,并提供一个访问它的全局访问点03.单例使用场景应用中某个实例对象需要频繁的被访问。应用中每次启动只会存在一个实例。如账号系统,数据库系统。04.思考几个问题网上有很多讲解单例模式的文章,但大部分都侧重讲解,如何来实现一个线程安全的单例。重点还是希望搞清楚下面这样几个问题。为什么要使用单例?单例存在哪些问题?单例与静态类的区别?有何替代的解决方案?05.为什么要使用单例单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。第一个是处理资源访问冲突;第二个是表示全局唯一类;06.处理资源访问冲突实战案例一:处理资源访问冲突先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示:public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/wangzheng/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { writer.write(mesasge); } } // Logger类的应用示例: public class UserController { private Logger logger = new Logger(); public void login(String username, String password) { // ...省略业务逻辑代码... logger.log(username + " logined!"); } } public class OrderController { private Logger logger = new Logger(); public void create(OrderVo order) { // ...省略业务逻辑代码... logger.log("Created an order: " + order.toString()); } }看完代码之后,先别着急看我下面的讲解,你可以先思考一下,这段代码存在什么问题。在上面的代码中,我们注意到,所有的日志都写入到同一个文件 /Users/wangzheng/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。为什么会出现互相覆盖呢?我们可以这么类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加 1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了 2,而是只加了 1。同理,这里的 log.txt 文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log() 函数。具体的代码实现如下所示:public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/wangzheng/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(this) { writer.write(mesasge); } } }不过,你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。07.表示全局唯一类从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。import java.util.concurrent.atomic.AtomicLong; public class IdGenerator { // AtomicLong是一个Java并发库中提供的一个原子变量类型, // 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作, // 比如下面会用到的incrementAndGet(). private AtomicLong id = new AtomicLong(0); private static final IdGenerator instance = new IdGenerator(); private IdGenerator() {} public static IdGenerator getInstance() { return instance; } public long getId() { return id.incrementAndGet(); } } // IdGenerator使用举例 long id = IdGenerator.getInstance().getId();实际上,今天讲到的两个代码实例(Logger、IdGenerator),设计的都并不优雅,还存在一些问题。更多内容GitHub:https://github.com/yangchong211博客:https://juejin.cn/user/1978776659695784博客汇总:https://github.com/yangchong211/YCBlogs
目录介绍01.磁盘沙盒的概述1.1 项目背景说明1.2 沙盒作用1.3 设计目标02.Android存储概念2.1 存储划分介绍2.2 机身内部存储2.3 机身外部存储2.4 SD卡外部存储2.5 总结和梳理下03.方案基础设计3.1 整体架构图3.2 UML设计图3.3 关键流程图3.4 接口设计图3.5 模块间依赖关系04.一些技术要点说明4.1 使用队列管理Fragment栈4.2 File文件列表4.3 不同版本访问权限4.4 访问文件操作4.5 10和11权限说明4.6 分享文件给第三方4.7 打开图片资源4.8 为何需要FileProvider4.9 跨进程IPC通信05.其他设计实践说明5.1 性能设计5.2 稳定性设计5.3 debug依赖设计01.磁盘沙盒的概述1.1 项目背景说明app展示在数据量多且刷新频繁的情况下,为提升用户体验,通常会对上次已有数据做内存缓存或磁盘缓存,以达到快速展示数据的目的。缓存的数据变化是否正确、缓存是否起到对应作用是QA需要重点测试的对象。android缓存路径查看方法有哪些呢?将手机打开开发者模式并连接电脑,在pc控制台输入cd /data/data/目录,使用adb主要是方便测试(删除,查看,导出都比较麻烦)。如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。1.2 沙盒作用可以通过该工具查看缓存文件快速查看data/data/包名目录下的缓存文件。快速查看/sdcard/Android/data/包名下存储文件。对缓存文件处理支持查看file文件列表数据,打开缓存文件查看数据详情。还可以删除缓存对应的文件或者文件夹,并且友好支持分享到外部。能够查看缓存文件修改的信息,修改的时间,缓存文件的大小,获取文件的路径等等。都是在可视化界面上处理。1.3 设计目标可视化界面展示多种处理文件操作针对file文件夹,或者file文件,长按可以出现弹窗,让测试选择是否删除文件。点击file文件夹,则拿到对应的文件列表,然后展示。点击file直到是具体文件(文本,图片,db,json等非file文件夹)跳转详情。一键接入该工具FileExplorerActivity.startActivity(MainActivity.this);开源项目地址:https://github.com/yangchong211/YCAndroidTool02.Android存储基本概念2.1 存储划分介绍存储划分介绍手机空间存储划分为两部分:1、机身存储;2、SD卡外部存储机身存储划分为两部分:1、内部存储;2、外部存储机身内部存储放到data/data目录下的缓存文件,一般使用adb无法查看该路径文件,私有的。程序卸载后,该目录也会被删除。机身外部存储放到/storage/emulated/0/目录下的文件,有共享目录,还有App外部私有目录,还有其他目录。App卸载的时候,相应的app创建的文件也会被删除。SD卡外部存储放到sd库中目录下文件,外部开放的文件,可以查看。2.2 机身内部存储想一下平时使用的持久化方案:这些文件都是默认放在内部存储里。SharedPreferences---->适用于存储小文件数据库---->存储结构比较复杂的大文件如果包名为:com.yc.helper,则对应的内部存储目录为:/data/data/com.yc.helper/第一个"/"表示根目录,其后每个"/"表示目录分割符。内部存储里给每个应用按照其包名各自划分了目录每个App的内部存储空间仅允许自己访问(除非有更高的权限,如root),程序卸载后,该目录也会被删除。机身内部存储一般存储那些文件呢?大概有以下这些cache-->存放缓存文件code_cache-->存放运行时代码优化等产生的缓存databases-->存放数据库文件files-->存放一般文件lib-->存放App依赖的so库 是软链接,指向/data/app/ 某个子目录下shared_prefs-->存放 SharedPreferences 文件那么怎么通过代码访问到这些路径的文件呢?代码如下所示context.getCacheDir().getAbsolutePath() context.getCodeCacheDir().getAbsolutePath() //databases 直接通过getDatabasePath(name)获取 context.getFilesDir().getAbsolutePath() //lib,暂时还不知道怎么获取该路径 //shared_prefs 直接通过SharedPreferences获取2.3 机身外部存储存放位置,主要有那些?如下所示,根目录下几个需要关注的目录:/data/ 这个是前面说的私有文件/sdcard/ /sdcard/是软链接,指向/storage/self/primary/storage/ /storage/self/primary/是软链接,指向/storage/emulated/0/也就是说/sdcard/、/storage/self/primary/ 真正指向的是/storage/emulated/0/下面这个是用adb查看 /storage/emulated/0 路径资源a51x:/storage $ ls emulated self a51x:/storage $ cd emulated/ a51x:/storage/emulated $ ls ls: .: Permission denied 1|a51x:/storage/emulated $ cd 0 a51x:/storage/emulated/0 $ ls //省略 /storage/emulated/0 下的文件然后来看下 /storage/emulated/0/ 存储的资源有哪些?如下,分为三部分:第一种:共享存储空间也就是所有App共享的部分,比如相册、音乐、铃声、文档等:DCIM/ 和 Pictures/-->存储图片DCIM/、Movies/ 和 Pictures-->存储视频Alarms/、Audiobooks/、Music/、Notifications/、Podcasts/ 和 Ringtones/-->存储音频文件Download/-->下载的文件Documents-->存储如.pdf类型等文件第二种:App外部私有目录Android/data/--->存储各个App的外部私有目录。与内部存储类似,命名方式是:Android/data/xx------>xx指应用的包名。如:/sdcard/Android/data/com.yc.helper第三种:其它目录比如各个App在/sdcard/目录下创建的目录,如支付宝创建的目录:alipay/,高德创建的目录:amap/,腾讯创建的目录:com.tencent.xx/等。那么怎么通过代码访问到这些路径的文件呢?代码如下所示第一种:通过ContentProvider访问,共享存储空间中的图片,视频,音频,文档等资源第二种:可以看出再/sdcard/Android/data/目录下生成了com.yc.helper/目录,该目录下有两个子目录分别是:files/、cache/。当然也可以选择创建其它目录。App卸载的时候,两者都会被清除。context.getExternalCacheDir().getAbsolutePath(); context.getExternalFilesDir(null).getAbsolutePath();第三种:只要拿到根目录,就可以遍历寻找其它子目录/文件。2.4 SD卡外部存储当给设备插入SD卡后,查看其目录:/sdcard/ ---> 依然指向/storage/self/primary,继续来看/storage/,可以看出,多了sdcard1,软链接指向了/storage/77E4-07E7/。访问方式,跟获取外部存储-App私有目录方式一样。File[] fileList = context.getExternalFilesDirs(null);返回File对象数组,当有多个外部存储时候,存储在数组里。返回的数组有两个元素,一个是自带外部存储存储,另一个是插入的SD卡。2.5 总结和梳理下Android存储有三种:手机内部存储、手机自带外部存储、SD卡扩展外部存储等。内部存储与外部存储里的App私有目录相同点:1、属于App专属,App自身访问两者无需任何权限。2、App卸载后,两者皆被删除。3、两者目录下增加的文件最终会被统计到"设置->存储和缓存"里。不同点:/data/data/com.yc.helper/ 位于内部存储,一般用于存储容量较小的,私密性较强的文件。而/sdcard/Android/data/com.yc.helper/ 位于外部存储,作为App私有目录,一般用于存储容量较大的文件,即使删除了也不影响App正常功能。在设置里的"存储与缓存"项,有清除数据和清除缓存,两者有何区别?当点击"清除数据" 时:内部存储/data/data/com.yc.helper/cache/、 /data/data/com.yc.helper/code_cache/目录会被清空外部存储/sdcard/Android/data/com.yc.helper/cache/ 会被清空当点击"清除缓存" 时:内部存储/data/data/com.yc.helper/下除了lib/,其余子目录皆被删除外部存储/sdcard/Android/data/com.yc.helper/被清空这种情况,相当于删除用户sp,数据库文件,相当于重置了app04.一些技术要点说明4.1 使用队列管理Fragment栈该磁盘沙盒file工具页面的组成部分是这样的FileExplorerActivity + FileExplorerFragment(多个,file列表页面) + TextDetailFragment(一个,file详情页面)针对磁盘file文件列表FileExplorerFragment页面,点击file文件item如果是文件夹则是继续打开跳转到file文件列表FileExplorerFragment页面,否则跳转到文件详情页面处理任务栈返回逻辑。举个例子现在列表FileExplorerFragment当作B,文件详情页面当作C,宿主Activity当作A。也就是说,点击返回键,依次关闭了fragment直到没有,回到宿主activity页面。再次点击返回键,则关闭activity!可能存在的任务栈是:打开A1->打开B1->打开C1那么点击返回键按钮,返回关闭的顺序则是:关闭C1->关闭B1->关闭A1Fragment回退栈处理方式第一种方案:创建一个栈(先进后出),打开一个FileExplorerFragment列表页面(push一个fragment对象到队列中),关闭一个列表页面(remove最上面那个fragment对象,然后调用FragmentManager中popBackStack操作关闭fragment)第二种方案:通过fragmentManager获取所有fragment对象,返回一个list,当点击返回的时候,调用popBackStack移除最上面一个具体处理该场景中回退逻辑首先定义一个双端队列ArrayDeque,用来存储和移除元素。内部使用数组实现,可以当作栈来使用,功能非常强大。当开启一个fragment页面的时候,调用push(相当于addFirst在栈顶添加元素)来存储fragment对象。代码如下所示public void showContent(Class<? extends Fragment> target, Bundle bundle) { try { Fragment fragment = target.newInstance(); if (bundle != null) { fragment.setArguments(bundle); } FragmentManager fm = getSupportFragmentManager(); FragmentTransaction fragmentTransaction = fm.beginTransaction(); fragmentTransaction.add(android.R.id.content, fragment); //push等同于addFirst,添加到第一个 mFragments.push(fragment); //add等同于addLast,添加到最后 //mFragments.add(fragment); fragmentTransaction.addToBackStack(""); //将fragment提交到任务栈中 fragmentTransaction.commit(); } catch (InstantiationException exception) { FileExplorerUtils.logError(TAG + exception.toString()); } catch (IllegalAccessException exception) { FileExplorerUtils.logError(TAG + exception.toString()); } }当关闭一个fragment页面的时候,调用removeFirst(相当于弹出栈顶的元素)移除fragment对象。代码如下所示@Override public void onBackPressed() { if (!mFragments.isEmpty()) { Fragment fragment = mFragments.getFirst(); if (fragment!=null){ //移除最上面的一个 mFragments.removeFirst(); } super.onBackPressed(); //如果fragment栈为空,则直接关闭activity if (mFragments.isEmpty()) { finish(); } } else { super.onBackPressed(); } } /** * 回退fragment任务栈操作 * @param fragment fragment */ public void doBack(Fragment fragment) { if (mFragments.contains(fragment)) { mFragments.remove(fragment); FragmentManager fm = getSupportFragmentManager(); //回退fragment操作 fm.popBackStack(); if (mFragments.isEmpty()) { //如果fragment栈为空,则直接关闭宿主activity finish(); } } }4.2 File文件列表获取文件列表,主要包括,data/data/包名目录下的缓存文件。/sdcard/Android/data/包名下存储文件。/** * 初始化默认文件。注意:加External和不加(默认)的比较 * 相同点:1.都可以做app缓存目录。2.app卸载后,两个目录下的数据都会被清空。 * 不同点:1.目录的路径不同。前者的目录存在外部SD卡上的。后者的目录存在app的内部存储上。 * 2.前者的路径在手机里可以直接看到。后者的路径需要root以后,用Root Explorer 文件管理器才能看到。 * * @param context 上下文 * @return 列表 */ private List<File> initDefaultRootFileInfos(Context context) { List<File> fileInfos = new ArrayList<>(); //第一个是文件父路径 File parentFile = context.getFilesDir().getParentFile(); if (parentFile != null) { fileInfos.add(parentFile); } //路径:/data/user/0/com.yc.lifehelper //第二个是缓存文件路径 File externalCacheDir = context.getExternalCacheDir(); if (externalCacheDir != null) { fileInfos.add(externalCacheDir); } //路径:/storage/emulated/0/Android/data/com.yc.lifehelper/cache //第三个是外部file路径 File externalFilesDir = context.getExternalFilesDir((String) null); if (externalFilesDir != null) { fileInfos.add(externalFilesDir); } //路径:/storage/emulated/0/Android/data/com.yc.lifehelper/files return fileInfos; }4.3 不同版本访问权限Android 6.0 之前访问方式Android 6.0 之前是无需申请动态权限的,在AndroidManifest.xml 里声明存储权限。就可以访问共享存储空间、其它目录下的文件。<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />Android 6.0 之后的访问方式Android 6.0 后需要动态申请权限,除了在AndroidManifest.xml 里声明存储权限外,还需要在代码里动态申请。//申请权限 if (ContextCompat.checkSelfPermission(mActivity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(mActivity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, CODE); } 4.4 访问文件操作权限申请成功后,即可对自带外部存储之共享存储空间和其它目录进行访问。分别以共享存储空间和其它目录为例,阐述访问方式:访问媒体文件(共享存储空间)。目的是拿到媒体文件的路径,有两种方式获取路径:以图片为例,假设图片存储在/sdcard/Pictures/目录下。路径:/storage/emulated/0/Pictures/yc.png,拿到路径后就可以解析并获取Bitmap。//获取目录:/storage/emulated/0/ File rootFile = Environment.getExternalStorageDirectory(); String imagePath = rootFile.getAbsolutePath() + File.separator + Environment.DIRECTORY_PICTURES + File.separator + "yc.png"; Bitmap bitmap = BitmapFactory.decodeFile(imagePath);通过MediaStore获取路径ContentResolver contentResolver = context.getContentResolver(); Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null); while(cursor.moveToNext()) { String imagePath = cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DATA)); Bitmap bitmap = BitmapFactory.decodeFile(imagePath); break; }还有一种不直接通过路径访问的方法,通过MediaStore获取Uri。与直接拿到路径不同的是,此处拿到的是Uri。图片的信息封装在Uri里,通过Uri构造出InputStream,再进行图片解码拿到Bitmapprivate void getImagePath(Context context) { ContentResolver contentResolver = context.getContentResolver(); Cursor cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null); while(cursor.moveToNext()) { //获取唯一的id long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); //通过id构造Uri Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); openUri(uri); break; } }访问文档和其它文件(共享存储空间)。直接构造路径。与媒体文件一样,可以直接构造路径访问。访问其它目录直接构造路径。与媒体文件一样,可以直接构造路径访问。总结一下共同点访问目录/文件可通过如下两个方法:1、通过路径访问。路径可以直接构造也可以通过MediaStore获取。 2、通过Uri访问。Uri可以通过MediaStore或者SAF(存储访问框架,通过intent调用startActivity访问)获取。4.5 10和11权限说明Android10权限改变比如能够直接在/sdcard/目录下创建目录/文件。可以看出/sdcard/目录下,如淘宝、qq、qq浏览器、微博、支付宝等都自己建了目录。这么看来,导致目录结构很乱,而且App卸载后,对应的目录并没有删除,于是就是遗留了很多"垃圾"文件,久而久之不处理,用户的存储空间越来越小。之前文件创建弊端如下卸载App也不能删除该目录下的文件在设置里"清除数据"或者"清除缓存"并不能删除该目录下的文件App可以随意修改其它目录下的文件,如修改别的App创建的文件等,不安全为什么要在/sdcard/目录下新建app存储的目录此处新建的目录不会被设置里的App存储用量统计,让用户"看起来"自己的App占用的存储空间很小。还有就是方便操作文件Android 10.0访问变更Google在Android 10.0上重拳出击了。引入Scoped Storage。简单来说有好几个版本:作用域存储、分区存储、沙盒存储。分区存储原理:1、App访问自身内部存储空间、访问外部存储空间-App私有目录不需要任何权限(这个与Android 10.0之前一致)2、外部存储空间-共享存储空间、外部存储空间-其它目录 App无法通过路径直接访问,不能新建、删除、修改目录/文件等3、外部存储空间-共享存储空间、外部存储空间-其它目录 需要通过Uri访问4.6 分享文件给第三方这里直接说分享内部文件给第三方,大概的思路如下所示:第一步:先判断是否有读取文件的权限,如果没有则申请;如果有则进行第二步;第二步:先把文件转移到外部存储文件,为何要这样操作,主要是解决data/data下目前文件无法直接分享问题,因此需要将目标文件拷贝到外部路径第三步:通过intent发送,FileProvider拿到对应路径的uri,最后调用startActivity进行分享文件。大概的代码如下所示if (ContextCompat.checkSelfPermission(mActivity,Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(mActivity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, CODE); } else { //先把文件转移到外部存储文件 File srcFile = new File(mFile.getPath()); String newFilePath = AppFileUtils.getFileSharePath() + "/fileShare.txt"; File destFile = new File(newFilePath); //拷贝文件,将data/data源文件拷贝到新的目标文件路径下 boolean copy = AppFileUtils.copyFile(srcFile, destFile); if (copy) { //分享 boolean shareFile = FileShareUtils.shareFile(mActivity, destFile); if (shareFile) { Toast.makeText(getContext(), "文件分享成功", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(getContext(), "文件分享失败", Toast.LENGTH_SHORT).show(); } } else { Toast.makeText(getContext(), "文件保存失败", Toast.LENGTH_SHORT).show(); } }4.7 打开图片资源首先判断文件,是否是图片资源,如果是图片资源,则跳转到打开图片详情。目前只是根据文件的后缀名来判断(对文件名称以.进行裁剪获取后缀名)是否是图片。if (FileExplorerUtils.isImage(fileInfo)) { Bundle bundle = new Bundle(); bundle.putSerializable("file_key", fileInfo); showContent(ImageDetailFragment.class, bundle); } 打开图片跳转详情,这里面为了避免打开大图OOM,因此需要对图片进行压缩,目前该工具主要是内存压缩和尺寸缩放方式。大概的原理如下例如,我们的原图是一张 2700 1900 像素的照片,加载到内存就需要 19.6M 内存空间,但是,我们需要把它展示在一个列表页中,组件可展示尺寸为 270 190,这时,我们实际上只需要一张原图的低分辨率的缩略图即可(与图片显示所对应的 UI 控件匹配),那么实际上 270 * 190 像素的图片,只需要 0.2M 的内存即可。这个采用缩放比压缩。加载图片,先加载到内存,再进行操作吗,可以如果先加载到内存,好像也不太对,这样只接占用了 19.6M + 0.2M 2份内存了,而我们想要的是,在原图不加载到内存中,只接将缩放后的图片加载到内存中,可以实现吗?进行内存压缩,要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。注意这个地方是核心,这个解析图片并没有生成bitmap对象(也就是说没有为它分配内存控件),而仅仅是拿到它的宽高等属性。然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。这一步会压缩图片。之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了。4.8 为何需要FileProvider4.8.1 文件共享基础概念了解文件共享的基础知识提到文件共享,首先想到就是在本地磁盘上存放一个文件,多个应用都可以访问它,如下:理想状态下只要知道了文件的存放路径,那么各个应用都可以读写它。比如相册里的图片或者视频存放目录:/sdcard/DCIM/、/sdcard/Pictures/ 、/sdcard/Movies/。文件共享方式是如何理解一个常见的应用场景:应用A里检索到一个文件yc.txt,它无法打开,于是想借助其它应用打开,这个时候它需要把待打开的文件路径告诉其它应用。对应案例就是,把磁盘文件分享到qq。这就涉及到了进程间通信。Android进程间通信主要手段是Binder,而四大组件的通信也是依靠Binder,因此我们应用间传递路径可以依靠四大组件。4.8.2 7.0前后对文件处理方式Android 7.0 之前使用,传递路径可以通过UriIntent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); //通过路径,构造Uri。设置Intent,附带Uri,然后通过intent跨进程通信 Uri uri = Uri.fromFile(new File(external_filePath)); intent.setData(uri); startActivity(intent);接收方在收到Intent后,拿出Uri,通过:filePath = uri.getEncodedPath() 拿到发送方发送的原始路径后,即可读写文件。然而此种构造Uri方式在Android7.0(含)之后被禁止了,若是使用则抛出异常,异常是FileUriExposedException。这种方式缺点如下:第一发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障;第二发送方传递的文件路径接收方可能没有读取权限,导致接收异常。Android 7.0(含)之后如何解决上面两个缺点问题对第一个问题:可以将具体路径替换为另一个字符串,类似以前密码本的感觉,比如:"/storage/emulated/0/com.yc.app/yc.txt" 替换为"file/yc.txt",这样接收方收到文件路径完全不知道原始文件路径是咋样的。那么会导致另一个额外的问题:接收方不知道真实路径,如何读取文件呢?对第二个问题既然不确定接收方是否有打开文件权限,那么是否由发送方打开,然后将流传递给接收方就可以了呢?Android 7.0(含)之后引入了FileProvider,可以解决上述两个问题。4.8.3 FileProvider应用与原理第一步,定义自定义FileProvider并且注册清单文件public class ExplorerProvider extends FileProvider { } <!--既然是ContentProvider,那么需要像Activity一样在AndroidManifest.xml里声明:--> <!--android:authorities 标识ContentProvider的唯一性,可以自己任意定义,最好是全局唯一的。--> <!--android:name 是指之前定义的FileProvider 子类。--> <!--android:exported="false" 限制其他应用获取Provider。--> <!--android:grantUriPermissions="true" 授予其它应用访问Uri权限。--> <!--meta-data 囊括了别名应用表。--> <!--android:name 这个值是固定的,表示要解析file_path--> <!--android:resource 自己定义实现的映射表--> <provider android:name="com.yc.toolutils.file.ExplorerProvider" android:authorities="${applicationId}.fileExplorerProvider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_explorer_provider" /> </provider>第二步,添加路径映射表在/res/ 下建立xml 文件夹,然后再创建对应的映射表(xml),最终路径如下:/res/xml/file_explorer_provider.xml。<paths> <!--FileProvider需要读取映射表。--> <external-cache-path name="external_cache" path="." /> <cache-path name="cache" path="." /> <external-path name="external_path" path="." /> <files-path name="files_path" path="." /> <external-files-path name="external_files_path" path="." /> <root-path name="root_path" path="." /> </paths>第三步,使用ExplorerProvider来跨进程通信交互如何解决第一个问题,让接收方看不到具体文件的路径?如下所示,下面构造后,第三方应用收到此Uri后,并不能从路径看出我们传递的真实路径,这就解决了第一个问题。public static boolean shareFile(Context context, File file) { boolean isShareSuccess; try { if (null != file && file.exists()) { Intent share = new Intent(Intent.ACTION_SEND); //此处可发送多种文件 String absolutePath = file.getAbsolutePath(); //通过扩展名找到mimeType String mimeType = getMimeType(absolutePath); share.setType(mimeType); Uri uri; //判断7.0以上 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //第二个参数表示要用哪个ContentProvider,这个唯一值在AndroidManifest.xml里定义了 //若是没有定义MyFileProvider,可直接使用FileProvider替代 String authority = context.getPackageName() + ".fileExplorerProvider"; uri = FileProvider.getUriForFile(context,authority, file); } else { uri = Uri.fromFile(file); } //content://com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt //content 作为scheme; //com.yc.lifehelper.fileExplorerProvider 即为我们定义的 authorities,作为host; LogUtils.d("share file uri : " + uri); String encodedPath = uri.getEncodedPath(); //external_path/fileShare.txt //如此构造后,第三方应用收到此Uri后,并不能从路径看出我们传递的真实路径,这就解决了第一个问题: //发送方传递的文件路径接收方完全知晓,一目了然,没有安全保障。 LogUtils.d("share file uri encode path : " + encodedPath); share.putExtra(Intent.EXTRA_STREAM, uri); share.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); //赋予读写权限 share.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); Intent intent = Intent.createChooser(share, "分享文件"); //交由系统处理 context.startActivity(intent); isShareSuccess = true; } else { isShareSuccess = false; } } catch (Exception e) { e.printStackTrace(); isShareSuccess = false; } return isShareSuccess; }如何解决第二个问题,发送方传递的文件路径接收方可能没有读取权限,导致接收异常?通过FileProvider.getUriForFile为入口查看源码,应用间通过IPC机制,最后调用了openFile()方法,而FileProvider重写了该方法。4.9 跨进程IPC通信A应用(该demo)通过构造Uri,通过intent调用B(分享到QQ)应用A将path构造为Uri:应用A在启动的时候,会扫描AndroidManifest.xml 里的 FileProvider,并读取映射表构造为一个Map。还是以/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt 为例,当调用 FileProvider.getUriForFile(xx)时,遍历Map,找到最匹配条目,最匹配的即为external_file。因此会用external_file 代替原始路径,最终形成的Uri为:content://com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txtB应用(QQ)通过Uri构造输入流,将Uri解析成具体的路径应用B通过Uri(A传递过来的),解析成具体的file文件。先将Uri分离出external_file/fileShare.txt,然后通过external_file 从Map里找到对应Value 为:/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/,最后将fileShare.txt拼接,形成的路径为:/storage/emulated/0/com.yc.lifehelper.fileExplorerProvider/external_path/fileShare.txt现在来梳理整个流程:1、应用A使用FileProvider通过Map(映射表)将Path转为Uri,通过IPC 传递给应用B。2、应用B使用Uri通过IPC获取应用A的FileProvider。3、应用A使用FileProvider通过映射表将Uri转为Path,并构造出文件描述符。4、应用A将文件描述符返回给应用B,应用B就可以读取应用A发送的文件了。整个交互流程图如下05.其他设计实践说明5.1 性能设计这个暂无,因为是小工具,主要是在debug环境下依赖使用。代码逻辑并不复杂,不会影响App的性能。5.2 稳定性设计修改文件说明目前,针对文本文件,比如缓存的json数据,存储在文本文件中,之前测试说让该工具支持修改属性,考虑到修改json比较复杂,因此这里只是实现可以删除文本文件,或者修改文件名称的功能。针对图片文件,可以打开且进行了图片压缩,仅仅支持删除图片文件操作。针对sp存储的数据,是xml,这里可视化展示sp的数据,目前可以支持修改sp数据,测试童鞋这方便操作简单,提高某些场景的测试效率。为何不支持修改json读取文本文件,是一行行读取,修改数据编辑数据麻烦,而且修改完成后对json数据合法性判断也比较难处理。因此这里暂时不提供修改缓存的json数据,测试如果要看,可以通过分享到外部qq查看文件,或者直接查看,避免脏数据。5.3 debug依赖设计建议在debug下使用在小工具放到debug包名下,依赖使用。或者在gradle依赖的时候区分也可以。如下所示://在app包下依赖 apply from: rootProject.file('buildScript/fileExplorer.gradle') /** * 沙盒file工具配置脚本 */ println('gradle file explorer , init start') if (!isNeedUseExplorer()) { println('gradle file explorer , not need file explorer') return } println('gradle file isNeedUseExplorer = ture') dependencies { // 依赖 implementation('com.github.jacoco:runtime:0.0.23-SNAPSHOT') } //过滤,只在debug下使用 def isNeedUseJacoco() { Map<String, String> map = System.getenv() if (map == null) { return false } //拿到编译后的 BUILD_TYPE 和 CONFIG。具体看 BuildConfig 生成类的代码 boolean hasBuildType = map.containsKey("BUILD_TYPE") boolean hasConfig = map.containsKey("CONFIG") println 'gradle file explorer isNeedUseExplorer hasBuildType =====>' + hasBuildType + ',hasConfig = ' + hasConfig String buildType = "debug" String config = "debug" if (hasBuildType) { buildType = map.get("BUILD_TYPE") } if (hasConfig) { config = map.get("CONFIG") } println 'gradle file explorer isNeedUseExplorer buildType =====>' + buildType + ',config = ' + config if (buildType.toLowerCase() == "debug" && config.toLowerCase() == "debug" && isNotUserFile()) { println('gradle file explorer debug used') return true } println('gradle file explorer not use') //如果是正式包,则不使用沙盒file工具 return false } static def isNotUserFile() { //在debug下默认沙盒file工具,如果你在debug下不想使用沙盒file工具,则设置成false return true }demo地址:https://github.com/yangchong211/YCAndroidTool
目录介绍01.Element是什么东西02.Element源码的分析03.Element创建过程分析04.mount方法调用分析05.理解BuildContext01.Element是什么东西Widget和Element的关系知道最终的UI树其实是由一个个独立的Element节点构成。也说过组件最终的Layout、渲染都是通过RenderObject来完成的。从创建到渲染的大体流程是:根据Widget生成Element,然后创建相应的RenderObject并关联到Element.renderObject属性上,最后再通过RenderObject来完成布局排列和绘制。Element就是Widget在UI树具体位置的一个实例化对象。大多数Element只有唯一的renderObject,但还有一些Element会有多个子节点,如继承自RenderObjectElement的一些类,比如MultiChildRenderObjectElement。最终所有Element的RenderObject构成一棵树,我们称之为”Render Tree“即”渲染树“。总结一下,我们可以认为Flutter的UI系统包含三棵树:Widget树、Element树、渲染树。他们的依赖关系是:Element树根据Widget树生成,而渲染树又依赖于Element树。02.Element源码的分析2.1 部分源码分析Element源码实在太多了,这里我只是摘取部分代码abstract class Element extends DiagnosticableTree implements BuildContext { Element(Widget widget) : _widget = widget; RenderObject get renderObject { } @mustCallSuper void mount(Element parent, dynamic newSlot) { } Element updateChild(Element child, Widget newWidget, dynamic newSlot) { } @mustCallSuper void update(covariant Widget newWidget) { } @protected Element inflateWidget(Widget newWidget, dynamic newSlot) { } void detachRenderObject() { } void attachRenderObject(dynamic newSlot) { } @protected void deactivateChild(Element child) { } }2.2 Element生命周期说明重点看一下Element,Element的生命周期如下:1.Framework 调用Widget.createElement 创建一个Element实例,记为element2.Framework 调用 element.mount(parentElement,newSlot) ,mount方法中首先调用element所对应Widget的createRenderObject方法创建与element相关联的RenderObject对象,然后调用element.attachRenderObject方法将element.renderObject添加到渲染树中插槽指定的位置(这一步不是必须的,一般发生在Element树结构发生变化时才需要重新attach)。插入到渲染树后的element就处于“active”状态,处于“active”状态后就可以显示在屏幕上了(可以隐藏)。3.当有父Widget的配置数据改变时,同时其State.build返回的Widget结构与之前不同,此时就需要重新构建对应的Element树。为了进行Element复用,在Element重新构建前会先尝试是否可以复用旧树上相同位置的element,element节点在更新前都会调用其对应Widget的canUpdate方法,如果返回true,则复用旧Element,旧的Element会使用新Widget配置数据更新,反之则会创建一个新的Element。Widget.canUpdate主要是判断newWidget与oldWidget的runtimeType和key是否同时相等,如果同时相等就返回true,否则就会返回false。根据这个原理,当我们需要强制更新一个Widget时,可以通过指定不同的Key来避免复用。4.当有祖先Element决定要移除element 时(如Widget树结构发生了变化,导致element对应的Widget被移除),这时该祖先Element就会调用deactivateChild 方法来移除它,移除后element.renderObject也会被从渲染树中移除,然后Framework会调用element.deactivate 方法,这时element状态变为“inactive”状态。5.“inactive”态的element将不会再显示到屏幕。为了避免在一次动画执行过程中反复创建、移除某个特定element,“inactive”态的element在当前动画最后一帧结束前都会保留,如果在动画执行结束后它还未能重新变成“active”状态,Framework就会调用其unmount方法将其彻底移除,这时element的状态为defunct,它将永远不会再被插入到树中。6.如果element要重新插入到Element树的其它位置,如element或element的祖先拥有一个GlobalKey(用于全局复用元素),那么Framework会先将element从现有位置移除,然后再调用其activate方法,并将其renderObject重新attach到渲染树。开发者会直接操作Element树吗其实对于开发者来说,大多数情况下只需要关注Widget树就行,Flutter框架已经将对Widget树的操作映射到了Element树上,这可以极大的降低复杂度,提高开发效率。但是了解Element对理解整个Flutter UI框架是至关重要的,Flutter正是通过Element这个纽带将Widget和RenderObject关联起来,了解Element层不仅会帮助读者对Flutter UI框架有个清晰的认识,而且也会提高自己的抽象能力和设计能力。另外在有些时候,我们必须得直接使用Element对象来完成一些操作,比如获取主题Theme数据。03.Element创建过程分析Widget有一个抽象方法createElement(),用来创建Element的。这个方法的具体实现有很多,找一个上面我们分析过的SingleChildRenderObjectWidget,这个类非常简单,只有一个child,下面看一这个类的源码。abstract class SingleChildRenderObjectWidget extends RenderObjectWidget { const SingleChildRenderObjectWidget({ Key key, this.child }) : super(key: key); final Widget child; //注释 @override SingleChildRenderObjectElement createElement() => SingleChildRenderObjectElement(this); }这个类的继承关系,这个类继承RenderObjectWidget,构造函数也很简单,传入一个child。重要的是在注释处,这个类创建一个类,是SingleChildRenderObjectElement,通过名字猜想,这一定是一个Element了。Weight和Element的关系这里验证了,Weight和Element的关系,一个Widget有一个Element对象,是通过createElement()创建的。看一下SingleChildRenderObjectElement的继承关系class SingleChildRenderObjectElement extends RenderObjectElement { } abstract class RenderObjectElement extends Element { }SingleChildRenderObjectElement继承RenderObjectElement,而RenderObjectElement是一个Element04.mount方法调用分析首先看一下 SingleChildRenderObjectElement 类中 mount 方法代码class SingleChildRenderObjectElement extends RenderObjectElement { SingleChildRenderObjectElement(SingleChildRenderObjectWidget widget) : super(widget); @override void mount(Element parent, dynamic newSlot) { super.mount(parent, newSlot);//注释A _child = updateChild(_child, widget.child, null);//注释B } }这个方法是当新创建的元素第一次添加到树中时,框架会调用此函数。注释A处,调用了父类的mount,我们看一下父类的mount的方法。abstract class RenderObjectElement extends Element { RenderObjectElement(RenderObjectWidget widget) : super(widget); RenderObject _renderObject;//注释C @override void mount(Element parent, dynamic newSlot) { super.mount(parent, newSlot); _renderObject = widget.createRenderObject(this);//注释D attachRenderObject(newSlot); _dirty = false; } }在注释C处,我们发现RenderObjectElement还持有一个对象,这对象是RenderObject,Element分别持有Widget和RenderObject。这个三个对象是什么关系?一个Element包含一个RenderObject和一个Widget。注释D的地方,这里创建了一个RenderObject,调用的是Widget的createRenderObject方法05.如何理解BuildContext5.1 BuildContext是啥已经知道,StatelessWidget和StatefulWidget的build方法都会传一个BuildContext对象:Widget build(BuildContext context) {}那么BuildContext到底是什么呢,查看其定义,发现其是一个抽象接口类:abstract class BuildContext { }那这个context对象对应的实现类到底是谁呢?顺藤摸瓜,发现build调用是发生在StatelessWidget和StatefulWidget对应的StatelessElement和StatefulElement的build方法中,以StatelessElement为例:class StatelessElement extends ComponentElement { @override Widget build() => widget.build(this); }发现build传递的参数是this,很明显!这个BuildContext就是StatelessElement。同样,我们同样发现StatefulWidget的context是StatefulElement。但StatelessElement和StatefulElement本身并没有实现BuildContext接口,继续跟踪代码,发现它们间接继承自Element类,然后查看Element类定义,发现Element类果然实现了BuildContext接口:class Element extends DiagnosticableTree implements BuildContext { }至此真相大白,BuildContext就是widget对应的Element,所以我们可以通过context在StatelessWidget和StatefulWidget的build方法中直接访问Element对象。我们获取主题数据的代码Theme.of(context)内部正是调用了Element的inheritFromWidgetOfExactType()方法。5.2 BuildContext作用写代码后知道,在很多时候我们都需要使用这个context 做一些事,比如:Theme.of(context) //获取主题 Navigator.push(context, route) //入栈新路由 Localizations.of(context, type) //获取Local context.size //获取上下文大小 context.findRenderObject() //查找当前或最近的一个祖先RenderObject5.3 思考一下这个问题思考题:为什么build方法的参数不定义成Element对象,而要定义成BuildContext ?可以看到Element是Flutter UI框架内部连接widget和RenderObject的纽带,大多数时候开发者只需要关注widget层即可,但是widget层有时候并不能完全屏蔽Element细节,所以Framework在StatelessWidget和StatefulWidget中通过build方法参数又将Element对象也传递给了开发者,这样一来,开发者便可以在需要时直接操作Element对象。那么现在笔者提两个问题,请读者先自己思考一下:1.如果没有widget层,单靠Element层是否可以搭建起一个可用的UI框架?如果可以应该是什么样子?2.Flutter UI框架能不做成响应式吗?对于问题1答案当然是肯定的,因为我们之前说过widget树只是Element树的映射,我们完全可以直接通过Element来搭建一个UI框架。下面举一个例子:通过纯粹的Element来模拟一个StatefulWidget的功能,假设有一个页面,该页面有一个按钮,按钮的文本是一个9位数,点击一次按钮,则对9个数随机排一次序,代码如下:class HomeView extends ComponentElement{ HomeView(Widget widget) : super(widget); String text = "123456789"; @override Widget build() { Color primary=Theme.of(this).primaryColor; //1 return GestureDetector( child: Center( child: FlatButton( child: Text(text, style: TextStyle(color: primary),), onPressed: () { var t = text.split("")..shuffle(); text = t.join(); markNeedsBuild(); //点击后将该Element标记为dirty,Element将会rebuild }, ), ), ); } }上面build方法不接收参数,这一点和在StatelessWidget和StatefulWidget中build(BuildContext)方法不同。代码中需要用到BuildContext的地方直接用this代替即可,如代码注释1处Theme.of(this)参数直接传this即可,因为当前对象本身就是Element实例。当text发生改变时,我们调用markNeedsBuild()方法将当前Element标记为dirty即可,标记为dirty的Element会在下一帧中重建。实际上,State.setState()在内部也是调用的markNeedsBuild()方法。上面代码中build方法返回的仍然是一个widget,这是由于Flutter框架中已经有了widget这一层,并且组件库都已经是以widget的形式提供了,如果在Flutter框架中所有组件都像示例的HomeView一样以Element形式提供,那么就可以用纯Element来构建UI了HomeView的build方法返回值类型就可以是Element了。对于问题2答案当然也是肯定的,Flutter engine提供的dart API是原始且独立的,这个与操作系统提供的API类似,上层UI框架设计成什么样完全取决于设计者,完全可以将UI框架设计成Android风格或iOS风格,但这些事Google不会再去做,我们也没必要再去搞这一套,这是因为响应式的思想本身是很棒的,之所以提出这个问题,是因为笔者认为做与不做是一回事,但知道能不能做是另一回事,这能反映出我们对知识的理解程度。推荐:https://github.com/yangchong211/YCFlutterUtils
目录介绍01.Widget基础概念1.1 Widget概念1.2 Widget骨架1.3 Widget源码1.4 Widget不可变02.StatelessWidget源码03.StatefulWidget源码04.InheritedWidget源码05.Context是什么作用01.Widget基础概念1.1 Widget概念在Flutter中几乎所有的对象都是一个Widget。与原生开发中“控件”不同的是,Flutter中的Widget的概念更广泛,它不仅可以表示UI元素,也可以表示一些功能性的组件如:用于手势检测的 GestureDetector widget、用于APP主题数据传递的Theme等等,而原生开发中的控件通常只是指UI元素。在描述UI元素时可能会用到“控件”、“组件”这样的概念,读者心里需要知道他们就是widget,只是在不同场景的不同表述而已。由于Flutter主要就是用于构建用户界面的,所以,在大多数时候,可以认为widget就是一个控件,不必纠结于概念。1.2 Widget骨架Widget 的骨架常用的 StatefulWidget、StatelessWidget,再加上 (InheritedWidget) 或 ProxyWidget 和 RenderObjectWidget 都继承于 Widget 基类,他们整体构成了 Widget 的骨架。有状态 和 无状态StatelessWidget 无状态的 Widget,常见的子类如 Text、Container。StatefulWidget 有状态的 Widget,常用的子类有 Image、Navigator。ProxyWidget 为代理 Widget,可以快速追溯父节点,通常用来做数据共享,常见的子类 InheritedWidget,各种状态管理框架,如 provider 等正是基于它实现。什么叫做“状态”?Widget 在 Flutter 架构下设计为不可变的,通常情况下每一帧都会重新构建一个新的 Widget 对象,而无法知道之前的状态。StatefulWidget 通过关联一个 State 对象实现状态的保存。1.3 Widget源码Widget源码如下所示abstract class Widget extends DiagnosticableTree { const Widget({ this.key }); final Key key; @protected @factory Element createElement(); @override String toStringShort() { final String type = objectRuntimeType(this, 'Widget'); return key == null ? type : '$type-$key'; } @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense; } @override @nonVirtual bool operator ==(Object other) => super == other; @override @nonVirtual int get hashCode => super.hashCode; static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; } static int _debugConcreteSubtype(Widget widget) { return widget is StatefulWidget ? 1 : widget is StatelessWidget ? 2 : 0; } }主要方法和属性介绍Widget类继承自DiagnosticableTree,DiagnosticableTree即“诊断树”,主要作用是提供调试信息。Key: 这个key属性类似于React/Vue中的key,主要的作用是决定是否在下一次build时复用旧的widget,决定的条件在canUpdate()方法中。createElement():正如所述“一个Widget可以对应一个Element”;Flutter Framework在构建UI树时,会先调用此方法生成对应节点的Element对象。此方法是Flutter Framework隐式调用的,在我们开发过程中基本不会调用到。debugFillProperties(...) 复写父类的方法,主要是设置诊断树的一些特性。canUpdate(...)是一个静态方法,它主要用于在Widget树重新build时复用旧的widget,其实具体来说,应该是:是否用新的Widget对象去更新旧UI树上所对应的Element对象的配置;通过其源码我们可以看到,只要newWidget与oldWidget的runtimeType和key同时相等时就会用newWidget去更新Element对象的配置,否则就会创建新的Element。核心方法createElement()Widget类本身是一个抽象类,其中最核心的就是定义了createElement()接口。在Flutter开发中,我们一般都不用直接继承Widget类来实现一个新组件,相反,我们通常会通过继承StatelessWidget或StatefulWidget来间接继承Widget类来实现。StatelessWidget和StatefulWidget都是直接继承自Widget类,而这两个类也正是Flutter中非常重要的两个抽象类,它们引入了两种Widget模型。核心方法canUpdate()实际上就是比较两个 widget 的 runtimeType 和 key 是否相同。runtimeType 也就是类型,如果新老 widget 的类型都变了,显然需要重新创建 Element。key Flutter 中另一个核心的概念,key 的存在影响了 widget 的更新、复用流程,这里先不展开。默认情况下 widget 创建时不需传入 key,因此更多情况下只需要比较二者的类型,如果类型一样,那么当前节点的 Element 不需要重建,接下来继续调用 child.update 更新子树。1.4 Widget不可变Widget 是一个很重要的概念,但是Widget有一个更重重要的特性,就是Widget是immutable(不可变的),这是什么意思?拿 Opacity 为例给讲解,讲解之前先看一下Opacity的继承关系。(在讲源码之前我们先看一下Opacity的职责是什么,Opacity是一个能让他的孩子透明的组件,很简单也很容易理解。)Opacity继承自SingleChildRenderObjectWidget,这类只包含了一个child的Widget,它继承自RenderObjectWidget,RenderObjectWidget继承自Widget。class Opacity extends SingleChildRenderObjectWidget { } abstract class SingleChildRenderObjectWidget extends RenderObjectWidget { } abstract class RenderObjectWidget extends Widget { }然后看一下 Opacity 简单源码class Opacity extends SingleChildRenderObjectWidget { const Opacity({ Key key, @required this.opacity, Widget child, }) : super(key: key, child: child); final double opacity;//注释1 @override RenderOpacity createRenderObject(BuildContext context) {//注释2 return RenderOpacity( opacity: opacity ); } @override void updateRenderObject(BuildContext context, RenderOpacity renderObject) { renderObject ..opacity = opacity } }在注释1处声明了一个属性,这属性是final,也就除了构造函数能给这个属性赋值之外,没有其他的办法让这个值进行改变。那我们想改变这个值怎么办,唯一的办法就是创建一个新的Opacity。02.StatelessWidget源码StatelessWidget源码如下所示abstract class StatelessWidget extends Widget { const StatelessWidget({ Key key }) : super(key: key); @override StatelessElement createElement() => StatelessElement(this); @protected Widget build(BuildContext context); }StatelessWidget相对比较简单,它继承自Widget类,重写了createElement() 方法。StatelessElement 间接继承自Element类,与StatelessWidget相对应(作为其配置数据)。StatelessWidget用于不需要维护状态的场景。它通常在build方法中通过嵌套其它Widget来构建UI,在构建过程中会递归的构建其嵌套的Widget。03.StatefulWidget源码StatefulWidget源码如下所示abstract class StatefulWidget extends Widget { const StatefulWidget({ Key key }) : super(key: key); @override StatefulElement createElement() => StatefulElement(this); @protected @factory State createState(); }StatefulElement 间接继承自Element类,与StatefulWidget相对应(作为其配置数据)。StatefulElement 中可能会多次调用createState()来创建状态(State)对象。createState() 用于创建和Stateful widget相关的状态,它在Stateful widget的生命周期中可能会被多次调用。例如,当一个Stateful widget同时插入到widget树的多个位置时,Flutter framework就会调用该方法为每一个位置生成一个独立的State实例,其实,本质上就是一个StatefulElement对应一个State实例。理解树的概念在不同的场景可能指不同的意思,在说“widget树”时它可以指widget结构树,但由于widget与Element有对应关系(一可能对多)。在有些场景(Flutter的SDK文档中)也代指“UI树”的意思。而在stateful widget中,State对象也和StatefulElement具有对应关系(一对一),所以在Flutter的SDK文档中,可以经常看到“从树中移除State对象”或“插入State对象到树中”这样的描述。其实,无论哪种描述,其意思都是在描述“一棵构成用户界面的节点元素的树”,如果没有特别说明,都可抽象的认为它是“一棵构成用户界面的节点元素的树”。04.InheritedWidget源码4.1 InheritedWidget源码分析InheritedWidget源码如下所示abstract class InheritedWidget extends ProxyWidget { const InheritedWidget({ Key key, Widget child }) : super(key: key, child: child); @override InheritedElement createElement() => InheritedElement(this); @protected bool updateShouldNotify(covariant InheritedWidget oldWidget); }如果想自己实现一个类似主题变更后,更新相应 UI 的功能应该怎么做?大致思路应该就是一个观察者模式,凡是使用的主题数据的地方,需要向 主题中心 注册一个观察者,当主题数据发生 改变 时,主题中心依次通知各个观察者进行 UI 更新。这里有个问题需要解决,如何定义 数据改变 ?事实上,数据是否改变是由业务方决定的,因此这里需要抽象出相应接口,来看 InheritedWidget 结构。核心就是 updateShouldNotify 方法入参为原始的 widget,返回值为布尔值,业务方需要实现此方法,判断是否需要将变化通知到各个观察者。4.2 注册和通知流程举一个常见例子比如,app设置了theme主题,当修改了theme主题颜色,是怎么修改全局所有页面的theme状态的呢?这个就用到了注册和通知的功能,接着往下看:注册流程假设 StudyWidget 是我们业务侧的 Widget,其内部使用了 Theme.of(context) 方法获取任意主题信息后,会经一系列调用,最终将这个 context——StudyWidget 对应的 Element 对象,注册到 InheritedElement的成员变量 Map<Element, Object> _dependents 中。另外需要注意,之所以在第二步中,可以找到父 InheritedElement,是因为在 Element 的 mount 过程中,会将父 Widget 中保存的 Map<Type, InheritedElement> _inheritedWidgets 集合,依次传递给子 Widget。如果自身也是 InheritedElement 也会添加到这个集合中。当我们使用 MaterialApp 或 CupertinoApp 作为根节点时,其内部已经帮我们封装了一个 Theme Widget,因此不需要我们额外的套一层作为注册了。通知流程当父 InheritedWidget 发生状态改变时,最终会调用到 InheritedElement 的 update 方法,我们以此作为通知的起点。可以看到,流程最终会将依赖的 Element 标脏,在下一帧重绘时将会更新对应 Widget 的状态。至此,InheritedWidget 整体的注册、通知流程结束。05.Context是什么作用什么是Contextbuild方法有一个context参数,它是BuildContext类的一个实例,表示当前widget在widget树中的上下文,每一个widget都会对应一个context对象(因为每一个widget都是widget树上的一个节点)。实际上,context是当前widget在widget树中位置中执行”相关操作“的一个句柄,比如它提供了从当前widget开始向上遍历widget树以及按照widget类型查找父级widget的方法。下面是在子树中获取父级widget的一个示例:class ContextRoute extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Context测试"), ), body: Container( child: Builder(builder: (context) { // 在Widget树中向上查找最近的父级`Scaffold` widget Scaffold scaffold = context.findAncestorWidgetOfExactType<Scaffold>(); // 直接返回 AppBar的title, 此处实际上是Text("Context测试") return (scaffold.appBar as AppBar).title; }), ), ); } }推荐:https://github.com/yangchong211/YCFlutterUtils
目录介绍01.Flutter三棵树背景02.Flutter中的三棵树03.Flutter三棵树关系04.运行时三棵树结构05.三棵树的作用介绍Flutter三棵树背景1.1 先思考一些问题Widget与Element是什么关系?它们是一一对应的还是怎么理解?createState 方法在什么时候调用?state 里面为啥可以直接获取到 widget 对象?Widget 频繁更改创建是否会影响性能?复用和更新机制是什么样的?Widget、Element、RenderObject 三棵树之间的关系是怎样的?1.2 Flutter中Dom树如何理解 DOM 树这个概念它由页面中每一个控件组成,这些控件所形成的一种天然的嵌套关系使其可以表示为 “树” 结构,可以将这个概念应用在 Flutter 中。例如默认的计数器应用的结构如下图:02.Flutter中的三棵树即Widget树、Element树和RenderObject树。Widget树:控件的配置信息,不涉及渲染,更新代价极低。RenderObject树:真正的UI渲染树,负责渲染UI,更新代价极大。Element树:Widget树和RenderObject树之间的粘合剂,负责将Widget树的变更以最低的代价映射到RenderObject树上。Widget 树我们平时用 Widget 使用声明式的形式写出来的界面,可以理解为 Widget 树,这是要介绍的第一棵树。Widget的功能是“描述一个UI元素的配置数据”,它就是说,Widget其实并不是表示最终绘制在设备屏幕上的显示元素,而它只是描述显示元素的一个配置数据。RenderObject 树Flutter 引擎需要把我们写的 Widget 树的信息都渲染到界面上,这样人眼才能看到,跟渲染有关的当然有一颗渲染树 RenderObject tree,这是第二颗树,渲染树节点叫做 RenderObject,这个节点里面处理布局、绘制相关的事情。这两个树的节点并不是一一对应的关系,有些 Widget是要显示的,有些 Widget ,比如那些继承自 StatelessWidget & StatefulWidget 的 Widget 只是将其他 Widget 做一个组合,这些 Widget 本身并不需要显示,因此在 RenderObject 树上并没有相对应的节点。Element 树Widget 树是非常不稳定的,动不动就执行 build方法,一旦调用 build 方法意味着这个 Widget 依赖的所有其他 Widget 都会重新创建,如果 Flutter 直接解析 Widget树,将其转化为 RenderObject 树来直接进行渲染,那么将会是一个非常消耗性能的过程,那对应的肯定有一个东西来消化这些变化中的不便,来做cache。因此,这里就有另外一棵树 Element 树。Element 树这一层将 Widget 树的变化(类似 React 虚拟 DOM diff)做了抽象,可以只将真正需要修改的部分同步到真实的 RenderObject 树中,最大程度降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个渲染视图树重建。03.Flutter三棵树关系3.1 三棵树架构关系三棵树架构图总结的关系widget 树和 Element 树节点是一一对应关系,每一个 Widget 都会有其对应的 Element,但是 RenderObject 树则不然,只有需要渲染的 Widget 才会有对应的节点。Element 树相当于一个中间层,大管家,它对 Widget 和 RenderObject 都有引用。当 Widget 不断变化的时候,将新 Widget 拿到 Element 来进行对比,看一下和之前保留的 Widget 类型和 Key 是否相同,如果都一样,那完全没有必要重新创建 Element 和 RenderObject,只需要更新里面的一些属性即可,这样可以以最小的开销更新 RenderObject,引擎在解析 RenderObject 的时候,发现只有属性修改了,那么也可以以最小的开销来做渲染。简单总结一下Widget 树就是配置信息的树,我们平时写代码写的就是这棵树。RenderObject 树是渲染树,负责计算布局,绘制,Flutter 引擎就是根据这棵树来进行渲染的。Element 树作为中间者,管理着将 Widget 生成 RenderObject和一些更新操作。举个通俗例子UI 渲染就像盖一栋大楼,Widget 代表图纸,表示我们想造怎样的大楼,RenderObject 是根据图纸干活的工人,而 Element 是监工,负责协调各方资源,统一调配,外部人员有事需要先找这个监工。3.2 三者创建关系图创建关系图用文字描述三者创建关系首先是 Widget 通过调用其 createElement 方法创建出 Element 对象。Element 继续调用其持有 Widget 对象(Stateless)或 State 对象(Stateful)的 build 方法创建其子 widget 对象。往复循环,继续创建子Element,子 Element 持有父 Element 的引用,因此最终会形成出一颗 Element 树。对于有 layout/paint 的能力控件,会创建 RenderObjectElement,在该 Element 的 mount 阶段会创建其对应的 RenderObject 对象。04.运行时三棵树结构4.1 三棵树结构认识了三棵树之后,那Flutter是如何创建布局的?以及三棵树之间他们是如何协同的呢?接下来就让我们通过一个简单的例子来剖析下它们内在的协同关系:class Tree extends StatelessWidget { @override Widget build(BuildContext context) { return Container( color: Colors.brown, child: Row( children: [ new Image.network( "https://p1.ssl.qhmsg.com/dr/220__/t01d5ccfbf9d4500c75.jpg", width: 100, height: 100, ), new Text( "从网络加载图片", style: TextStyle( fontSize: 16 ), ), ], ), ); } }当runApp()被调用时,第一时间会在后台发生以下事件:Flutter会构建包含Widget(Container,Row,Image,Text)的Widgets树;Flutter遍历Widget树,然后根据其中的Widget调用createElement()来创建相应的Element对象,最后将这些对象组建成Element树;接下来会创建第三个树,这个树中包含了与Widget对应的Element通过createRenderObject()创建的RenderObject;具体Flutter经过这三个步骤后的状态总结一下三棵树结构Widget Tree: Widget 是 Flutter 面向开发者的上层接口,我们通过 widget 的层层嵌套,会形成一颗 Widget 树,一个 Widget 可在多个位置复用。Flutter Framework 层为我们提供了一些常用的包装或者容器的 Widget,比如 Container,其内部继续嵌套了其他 Widget,如 Padding、Align 等等。所以,开发者编写的 Widget 树和实际生成的 Widget 树都会略有差别。如图中虚线圆形标注的 ColorBox、RawImage 等。Element Tree :每一个 Widget 都会对应一个 Element,只不过 Element 分类不同。RenderObject Tree:RenderObject 只负责最终的测量、布局和绘制,因此最终的 RenderObject Tree 是 Element Tree 剔除掉哪些包装,最后组织而成的 Tree。4.2 为何搞这多树分层:开发只关注widgetFramework 将复杂的内部设计、渲染逻辑与开发接口隔离开,应用层只需关注 Widget 开发即可。高效:提交绘制效率Tree 最大的共同特点就是快取,因为 Element、RenderObject 销毁重建成本很高,一旦可以复用 ,那么快取可以大幅减少这种开销。比如:当 Element 不需要重建时,更新 Widget 的引用就可以了;Layer Tree 的设计是将绘制图层分开,方便提取和合成,合成层中的 transform 和 opacity 效果,都只是几何变换、透明度变换等,不会触发 layout 和 paint,直接由 GPU 完成即可。05.三棵树的作用介绍5.1 简单了解更新操作简而言之是为了性能,为了复用Element从而减少频繁创建和销毁RenderObject。因为实例化一个RenderObject的成本是很高的,频繁的实例化和销毁RenderObject对性能的影响比较大,所以当Widget树改变的时候,Flutter使用Element树来比较新的Widget树和原来的Widget树://framework.dart @protected Element updateChild(Element child, Widget newWidget, dynamic newSlot) { if (newWidget == null) { if (child != null) deactivateChild(child); return null; } Element newChild; if (child != null) { assert(() { final int oldElementClass = Element._debugConcreteSubtype(child); final int newWidgetClass = Widget._debugConcreteSubtype(newWidget); hasSameSuperclass = oldElementClass == newWidgetClass; return true; }()); if (hasSameSuperclass && child.widget == newWidget) { if (child.slot != newSlot) updateSlotForChild(child, newSlot); newChild = child; } else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) { if (child.slot != newSlot) updateSlotForChild(child, newSlot); child.update(newWidget); assert(child.widget == newWidget); assert(() { child.owner._debugElementWasRebuilt(child); return true; }()); newChild = child; } else { deactivateChild(child); assert(child._parent == null); newChild = inflateWidget(newWidget, newSlot); } } else { newChild = inflateWidget(newWidget, newSlot); } assert(() { if (child != null) _debugRemoveGlobalKeyReservation(child); final Key key = newWidget?.key; if (key is GlobalKey) { key._debugReserveFor(this, newChild); } return true; }()); return newChild; } ... static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; }如果某一个位置的Widget和新Widget不一致,才需要重新创建Element;如果某一个位置的Widget和新Widget一致时(两个widget相等或runtimeType与key相等),则只需要修改RenderObject的配置,不用进行耗费性能的RenderObject的实例化工作了;因为Widget是非常轻量级的,实例化耗费的性能很少,所以它是描述APP的状态(也就是configuration)的最好工具;重量级的RenderObject(创建十分耗费性能)则需要尽可能少的创建,并尽可能的复用;5.2 更新时三棵树操作因为Widget是不可变的,当某个Widget的配置改变的时候,整个Widget树都需要被重建。例如当我们改变一个Text文本的时候,框架就会触发一个重建整个Widget树的动作。因为有了Element的存在,Flutter会比较新的Widget树中的第一个Widget和之前的Widget。接下来比较Widget树中之后Widget和之前Widget,以此类推,直到Widget树比较完成。 @override Widget build(BuildContext context) { return Container( color: Colors.brown, height: double.infinity, child: Row( children: [ new Image.network( "https://p1.ssl.qhmsg.com/dr/220__/t01d5ccfbf9d4500c75.jpg", width: 100, height: 100, ), new Text( "改变UI", style: TextStyle( fontSize: 16 ), ), ], ), ); }Flutter遵循一个最基本的原则:判断新的Widget和老的Widget是否是同一个类型:如果不是同一个类型,那就把Widget、Element、RenderObject分别从它们的树(包括它们的子树)上移除,然后创建新的对象;如果是一个类型,那就仅仅修改RenderObject中的配置,然后继续向下遍历。推荐:https://github.com/yangchong211/YCFlutterUtils
目录介绍01.flutter和原生之间交互02.MethodChanel流程03.MethodChanel使用流程04.MethodChanel代码实践05.EventChannel流程06.EventChannel基本流程07.EventChannel代码实现08.BasicMessageChannel流程09.BasicMessageChannel基本流程10.BasicMessageChannel代码实现11.Channel编解码器说明12.Channel通信可以子线程吗13.Channel通信传递稳定性14.onActivityResult如何实现推荐fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCVideoPlayer01.flutter和原生之间交互1.1 交互简单介绍官方给的通信方式看图片,channel通信方式从底层来看,Flutter和平台端通信的方式是发送异步的二进制消息,该基础通信方式在Flutter端由BinaryMessages来实现, 而在Android端是一个接口BinaryMessenger,其具体实现为FlutterNativeView,在iOS端是一个协议 FlutterBinaryMessenger,FlutterViewController遵守并实现了这个协议。flutter可以与native之间进行通信,帮助我们使用native提供的能力。通信是双向的,我们可以从Native层调用flutter层的dart代码,同时也可以从flutter层调用Native的代码。我们需要使用Platform Channels APIs进行通信,主要包括下面三种:MethodChannel:用于传递方法调用(method invocation)EventChannel:用于事件流的发送(event streams)BasicMessageChannel:用于传递字符串和半结构化的消息,这里什么叫做半结构化?下面会解释……channel通信是异步还是同步的为了保证用户界面在交互过程中的流畅性,无论是从Flutter向Native端发送消息,还是Native向Flutter发送消息都是以异步的形式进行传递的。那为何不使用同步来操作,下面会说到……几种channel应用场景分析MethodChannel使用场景:无论是Flutter端还是Native端都可以通过MethodChannel向对方平台发送两端提前定义好的方法名来调用对方平台相对应的消息处理逻辑并且带回返回值给被调用方。EventChannel的使用场景:更侧重于Native平台主动向Flutter平台,单向给Flutter平台发送消息,Flutter无法返回任何数据给Native端,EventChannel描述是单通的。可以类比Android里面的广播……BasicMessageChannel的使用场景:比如flutter想拍照,拍完照后的图片路径需要传给flutter,照片的路径发送可以使用BasicMessageChannel.Reply回复,也可以使用sendMessage主动再发一次消息。个人认为接收消息并回复消息属于一次通信,所以倾向于使用BasicMessageChannel.Reply。混合开发通常用那种channel只是混合开发通常涉及到两端频繁通信,个人更加倾向使用BasicMessageChannel,不分主客,使用和通信更方便。1.2 核心类重点说明MethodCall方法调用Java层封装,主要是数据类MethodChannel这个主要用户和dart进行方法通信,类MethodCallHandler这个java层处理dart层时间的接口,在通讯协议中属于上层接口,接口BinaryMessageHandlerjava层和dart层通讯的最底层抽象接口,面向二进制数据包,接口DartMessenger最底层用于接收JNI发送过来的数据。实现类DartExecutor配置、引导并开始执行Dart代码。BinaryMessenger的具体实现类FlutterViewNA用来承载flutter的容器viewIncomingMethodCallHandlerBinaryMessageHandler的实现类,用户接收底层发送过来的数据包,然后转发给MethodCallHandler,并对MethodCallHandler 发送过的结果进行打包发送给dart层。实现类FlutterJNIJNI层的封装用于跟底层引擎侧进行通讯02.MethodChannel流程其中最常用的是MethodChanel,MethodChanel的使用与在Android的JNI调用非常类似,但是MethodChanel更加简单,而且相对于JNI的同步调用MethodChanel的调用是异步的:从flutter架构图上可以看到,flutter与native的通信发生在Framework和Engine之间,framewrok内部会将MethodChannel以BinaryMessage的形式与Engine进行数据交换。03.MethodChanel使用流程3.1 flutter调用nativeflutter调用native步骤[native] 使用MethodChannel#setMethodCallHandler注册回调[flutter] 通过MethodChannel#invokeMethod发起异步调用[native] 调用native方法通过Result#success返回Result,出错时返回error[flutter] 收到native返回的Result如图所示3.2 native调用flutternative调用flutter与flutter调用native的顺序完全一致,只是[native]与[flutter]角色反调如图所示NA端使用MethodChannel首先定义Channel名称,需要保证是唯一的,在Flutter端需要使用同样的名称来创建MethodChannel。如果名称不一样,则会导致匹配不上……第一个参数:是messenger,类型是BinaryMessenger,是一个接口,代表消息信使,是消息发送与接收的工具;第二个参数:是name,就是Channel名称,和flutter定义的要一样;第三个参数:是codec,类型是MethodCodec,代表消息的编解码器,如果没有传该参数,默认使用StandardMethodCodec。04.MethodChanel代码实践4.1 native调用flutter定义好了MethodChannel之后调用setMethodCallHandler()方法设置消息处理回调,参数是MethodHandler类型,需要实现它的onMethodCall()方法。onMethodCall()方法有两个参数methodCall和result,methodCall记录了调用的方法信息,包括方法名和参数,result用于方法的返回值,可以通过result.success()方法返回信息给Flutter端。private void createChannel() { nativeChannel = new MethodChannel(binaryMessenger, METHOD_CHANNEL, StandardMethodCodec.INSTANCE); // 注册Handler实现 nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("android".equals(methodCall.method)) { //接收来自flutter的指令 String flutter = methodCall.argument("flutter"); //返回给flutter的参数 result.success("Na收到指令"); } } }); }可以通过invokeMethod方法让NA执行调用flutter方法。那么执行了flutter方法后需要回传数据,这个时候就需要用到Result接口呢,代码如下所示:HashMap<String , String> map = new HashMap<>(); map.put("invokeKey","你好,这个是从NA传递过来的数据"); //nativeChannel.resizeChannelBuffer(100); nativeChannel.invokeMethod("getFlutterResult", map , new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { tvContent.setText("测试内容:"+result); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { tvContent.setText("测试内容:flutter传递给na数据传递错误"); } @Override public void notImplemented() { } });事件接收处理端接收处理回调时onMethodCall(MethodCall call, MethodChannel.Result result)通过methodCall接收事件发送者传递回来的信息,通过Result把处理完的结果发送给事件发送方。通过methodCall.method:来区分不同函数名(方法)名以执行不同的业务逻辑,通过methodCall.hasArgument("key"):判断是否有某个key对应的value通过methodCall.argument("key"):获取key对应的value值通过result.success(object):把处理完的结果返回给事件发送方事件发送端处理事件发送方通过methodChannel.invokeMethod("方法名","要传递的参数")把需要传递的参数传递给事件监听者。 其中方法名:不能为空要传递的参数:可以为空,若不为空则必须为可Json序列化的对象。callback:可以为空,若不为空则表示执行了flutter方法后的回调监听状态4.2 flutter调用nativeFlutter使用MethodChannel在Flutter端同样需要定义一个MethodChannel,使用MethodChannel需要引入services.dart包,Channel名称要和Android端定义的相同。static const method = const MethodChannel('com.ycbjie.android/method');添加监听NA调用flutter方法的监听,flutter代码是setMethodCallHandler方法实现。return则表示flutter回传给NA的数据操作。method.setMethodCallHandler(nativeCallHandler); // 注册方法,等待被原生通过invokeMethod唤起 Future<dynamic> nativeCallHandler(MethodCall methodCall) async { switch (methodCall.method) { case "getFlutterResult": //获取参数 String paramsFromNative = await methodCall.arguments["invokeKey"]; print("原生android传递过来的参数为------ $paramsFromNative"); return "你好,这个是从flutter回传给NA的数据"; break; } }flutter是如何给NA发送消息的呢,直接调用invokeMethod方法,代码如下所示Future<Null> _jumpToNativeWithParams1() async { Map<String, String> map = { "flutter": "这是一条来自flutter的参数" }; String result = await method.invokeMethod('android', map); print(result); }05.EventChannel流程EventChannel用于从native向flutter发送通知事件,例如flutter通过其监听Android的重力感应变化等。与MethodChannel不同,EventChannel是native到flutter的单向调用,调用是多播(一对多)的,可以类比成Android的Brodecast广播。06.EventChannel基本流程照例先看一下API使用的基本流程:[native]EventChannel#setStreamHandler注册Handler实现[native]EventChannel初始化结束后,在StreamHandler#onLister回调中获取EventSink引用并保存[flutter]EventChannel#receiveBroadcastStream注册listener,建立监听[native]使用EventSink#sucess发送通知事件[flutter]接受到事件通知[native]通知结束时调用endOfStream结束如图所示07.EventChannel代码实现flutter端创建EventChannel,注册“包名/标识符”的channel名通过StreamSubscription#listen注册listener,其中cancelOnError参数表示遇到错误时是否自动结束监听class _MyHomePageState extends State<MyHomePage> { static const EventChannel _channel = const EventChannel('com.example.eventchannel/interop'); StreamSubscription _streamSubscription; String _platformMessage; void _enableEventReceiver() { _streamSubscription = _channel.receiveBroadcastStream().listen( (dynamic event) { print('Received event: $event'); setState(() { _platformMessage = event; }); }, onError: (dynamic error) { print('Received error: ${error.message}'); }, cancelOnError: true); } void _disableEventReceiver() { if (_streamSubscription != null) { _streamSubscription.cancel(); _streamSubscription = null; } } @override initState() { super.initState(); _enableEventReceiver(); } @override void dispose() { super.dispose(); _disableEventReceiver(); }native(android)端通过EventChannel#setStreamHandler注册Handler实现初始化完成后,获取eventSink引用并保存eventSink发送事件通知通知结束时调用event#endOfStream,此时onCancel会被调用必要时,可通过evnetSink#error发送错误通知,flutter的StreamSubscription#onError会收到通知class MainActivity: FlutterActivity() { private lateinit var channel: EventChannel var eventSink: EventSink? = null override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) channel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.eventchannel/interop") channel.setStreamHandler( object : StreamHandler { override fun onListen(arguments: Any?, events: EventSink) { eventSink = events Log.d("Android", "EventChannel onListen called") Handler().postDelayed({ eventSink?.success("Android") //eventSink?.endOfStream() //eventSink?.error("error code", "error message","error details") }, 500) } override fun onCancel(arguments: Any?) { Log.w("Android", "EventChannel onCancel called") } }) } }08.BasicMessageChannel流程BasicMessageChannel用于在flutter和native互相发送消息,一方给另一方发送消息,收到消息之后给出回复。09.BasicMessageChannel基本流程flutter向native发送消息[flutter]创建BasicMessageChannel[native]通过BasicMessageChannel#MessageHandler注册Handler[flutter]通过BasicMessageChannel#send发送消息[native]BasicMessageChannel#MessageHandler#onMessage中接收消息,然后reply如图所示native向flutter发送消息流程也是一样的,只是将[flutter]与[native]反调如图所示10.BasicMessageChannel代码实现10.1flutter端flutter需要完成以下工作创建BasicMessageChannel通过BasicMessageChannel#send发送消息相对与其他Channel类型的创建,MessageChannel的创建除了channel名以外,还需要指定编码方式:BasicMessageChannel(String name, MessageCodec<T> codec, {BinaryMessenger binaryMessenger})发送的消息会以二进制的形式进行处理,所以要针对不同类型的数进行二进制编码编码类型 消息格式BinaryCodec 发送二进制消息时JSONMessageCodec 发送Json格式消息时StandardMessageCodec 发送基本型数据时StringCodec 发送String类型消息时代码class _MyHomePageState extends State<MyHomePage> { static const _channel = BasicMessageChannel('com.ycbjie.android/basic', StringCodec()); String _platformMessage; void _sendMessage() async { final String reply = await _channel.send('Hello World form Dart'); print(reply); } @override initState() { super.initState(); // Receive messages from platform _channel.setMessageHandler((String message) async { print('Received message = $message'); setState(() => _platformMessage = message); return 'Reply from Dart'; }); // Send message to platform _sendMessage(); }10.2 native(android)端android端完成以下工作:创建BasicMessageChannel通过setHandler注册MessageHandlerMessageHandler#onMessage回调中接收到message后,通过reply进行回复代码class MainActivity: FlutterActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) val channel = BasicMessageChannel( flutterEngine.dartExecutor.binaryMessenger, "com.ycbjie.android/basic", StringCodec.INSTANCE) // Receive messages from Dart channel.setMessageHandler { message, reply -> Log.d("Android", "Received message = $message") reply.reply("Reply from Android") } // Send message to Dart Handler().postDelayed({ channel.send("Hello World from Android") { reply -> Log.d("Android", "$reply") } }, 500) } }11.Channel编解码器说明11.1 什么是消息编解码器什么是消息编解码器在Flutter和平台间进行相互通信了,但是收发的数据都是二进制的,这就需要开发者考虑更多的细节,如字节顺序(大小端)和怎么表示更高级的消息类型,如字符串,map等。因此,Flutter 还提供了消息编解码器(Codec), 用于高级数据类型(字符串,map等)和二进制数据(byte)之间的转换,即消息的序列化和反序列化。消息编解码器种类有哪些MethodCodec:方法传递的编解码器抽象,接口JSONMethodCodec:MethodCodec的实现类,会把数据打包成json结构发送给dart,类StandardMethodCodec:MethodCodec的实现类,会把数据打包成默认格式发送给dart,类11.2 四种消息编解码器类型BinaryCodecMessageCodec的实现类,直接发送二进制数据BinaryCodec是最为简单的一种Codec,因为其返回值类型和入参的类型相同,均为二进制格式(Android中为ByteBuffer,iOS中为NSData)。实际上,BinaryCodec在编解码过程中什么都没做,只是原封不动将二进制数据消息返回而已。或许你会因此觉得BinaryCodec没有意义,但是在某些情况下它非常有用,比如使用BinaryCodec可以使传递内存数据块时在编解码阶段免于内存拷贝。StringCodecMessageCodec的实现类,负责解码和编码String类型的消息使用 UTF-8 编码格式对字符串数据进行编解码,在Android平台转换为 java.util.String 类型JSONMessageCodecMessageCodec的实现类,负责解码和编码Json类型的消息JSONMessageCodec用于处理 JSON 数据类型(字符串型,数字型,布尔型,null,只包含这些类型的数组,和key为string类型,value为这些类型的map),在编码过程中,数据会被转换为JSON字符串,然后在使用 UTF-8 格式转换为字节型。StandardMessageCodecMessageCodec的实现类,负责解码和编码默认类型的消息StandardMessageCodec 可以认为是 JSONMessageCodec 的升级版,能够处理的数据类型要比 JSONMessageCodec 更普遍一些,且在处理 int 型数据时,会根据 int 数据的大小来转为平台端的32位类型(int)或者是64位类型(long),StandardMessageCodec 也是 Flutter Platform channel 的默认编解码器11.3 编码器的源码分析下首先看下MessageCodecabstract class MessageCodec<T> { ByteData encodeMessage(T message); T decodeMessage(ByteData message); }11.4 看StandardMessageCodecStandardMessageCodec稍微复杂StandardMessageCodec在写入数据的时候,显示写入这个数据的类型值定义,然后在写入其对应的具体值,什么意思呢?查看一下如何写入指定类型的值,代码如下所示:protected void writeValue(ByteArrayOutputStream stream, Object value) { if (value == null || value.equals(null)) { stream.write(NULL); } else if (value == Boolean.TRUE) { stream.write(TRUE); } else if (value == Boolean.FALSE) { stream.write(FALSE); } else if (value instanceof Number) { if (value instanceof Integer || value instanceof Short || value instanceof Byte) { stream.write(INT); writeInt(stream, ((Number) value).intValue()); } else if (value instanceof Long) { stream.write(LONG); writeLong(stream, (long) value); } else if (value instanceof Float || value instanceof Double) { stream.write(DOUBLE); writeAlignment(stream, 8); writeDouble(stream, ((Number) value).doubleValue()); } else if (value instanceof BigInteger) { stream.write(BIGINT); writeBytes(stream, ((BigInteger) value).toString(16).getBytes(UTF8)); } else { throw new IllegalArgumentException("Unsupported Number type: " + value.getClass()); } } else if (value instanceof String) { stream.write(STRING); writeBytes(stream, ((String) value).getBytes(UTF8)); } else if (value instanceof byte[]) { stream.write(BYTE_ARRAY); writeBytes(stream, (byte[]) value); } else if (value instanceof int[]) { stream.write(INT_ARRAY); final int[] array = (int[]) value; writeSize(stream, array.length); writeAlignment(stream, 4); for (final int n : array) { writeInt(stream, n); } } else if (value instanceof long[]) { stream.write(LONG_ARRAY); final long[] array = (long[]) value; writeSize(stream, array.length); writeAlignment(stream, 8); for (final long n : array) { writeLong(stream, n); } } else if (value instanceof double[]) { stream.write(DOUBLE_ARRAY); final double[] array = (double[]) value; writeSize(stream, array.length); writeAlignment(stream, 8); for (final double d : array) { writeDouble(stream, d); } } else if (value instanceof List) { stream.write(LIST); final List<?> list = (List) value; writeSize(stream, list.size()); for (final Object o : list) { writeValue(stream, o); } } else if (value instanceof Map) { stream.write(MAP); final Map<?, ?> map = (Map) value; writeSize(stream, map.size()); for (final Entry<?, ?> entry : map.entrySet()) { writeValue(stream, entry.getKey()); writeValue(stream, entry.getValue()); } } else { throw new IllegalArgumentException("Unsupported value: " + value); } }查看一下如何读取指定类型的值,代码如下所示:protected Object readValueOfType(byte type, ByteBuffer buffer) { final Object result; switch (type) { case NULL: result = null; break; case TRUE: result = true; break; case FALSE: result = false; break; case INT: result = buffer.getInt(); break; case LONG: result = buffer.getLong(); break; case BIGINT: { final byte[] hex = readBytes(buffer); result = new BigInteger(new String(hex, UTF8), 16); break; } case DOUBLE: readAlignment(buffer, 8); result = buffer.getDouble(); break; case STRING: { final byte[] bytes = readBytes(buffer); result = new String(bytes, UTF8); break; } case BYTE_ARRAY: { result = readBytes(buffer); break; } case INT_ARRAY: { final int length = readSize(buffer); final int[] array = new int[length]; readAlignment(buffer, 4); buffer.asIntBuffer().get(array); result = array; buffer.position(buffer.position() + 4 * length); break; } case LONG_ARRAY: { final int length = readSize(buffer); final long[] array = new long[length]; readAlignment(buffer, 8); buffer.asLongBuffer().get(array); result = array; buffer.position(buffer.position() + 8 * length); break; } case DOUBLE_ARRAY: { final int length = readSize(buffer); final double[] array = new double[length]; readAlignment(buffer, 8); buffer.asDoubleBuffer().get(array); result = array; buffer.position(buffer.position() + 8 * length); break; } case LIST: { final int size = readSize(buffer); final List<Object> list = new ArrayList<>(size); for (int i = 0; i < size; i++) { list.add(readValue(buffer)); } result = list; break; } case MAP: { final int size = readSize(buffer); final Map<Object, Object> map = new HashMap<>(); for (int i = 0; i < size; i++) { map.put(readValue(buffer), readValue(buffer)); } result = map; break; } default: throw new IllegalArgumentException("Message corrupted"); } return result; }11.5 如何选择合适编解码器编解码的实现类并不复杂可以先了解一下这个比较能更好的理解数据传递,其实不关java上层使用那种方式,最终传递给底层数据都是固定格式,约定统一的数据格式双方才能识别出来,正常的来说用默认的编解码格式就可以了。关于四种解码器使用场景BinaryCodec暂未找到使用的场景StringCodec适用发送单一的字符串数据,数据量单一的情况,比如LifecycleChannelpublic void appIsInactive() { Log.v(TAG, "Sending AppLifecycleState.inactive message."); channel.send("AppLifecycleState.inactive"); }JSONMessageCodec适用数据量比较复杂的情况,比如有携带多个数据字段的传递,比如KeyEventChannelpublic void keyDown(@NonNull FlutterKeyEvent keyEvent) { Map<String, Object> message = new HashMap<>(); message.put("type", "keydown"); message.put("keymap", "android"); encodeKeyEvent(keyEvent, message); channel.send(message); }StandardMessageCodec默认的数据编解码,绝大多数的情况下使用默认的就可以了。比如:MethodChannel,EventChannel12.Channel通信可以子线程吗12.1 Android发送通信信息首先看一下Android发送通信信息,主要分析入口是:nativeChannel.invokeMethod("setNum", a , null);public void invokeMethod(String method, @Nullable Object arguments, Result callback) { messenger.send( name, codec.encodeMethodCall(new MethodCall(method, arguments)), callback == null ? null : new IncomingResultHandler(callback)); }最终定位找到DartMessenger类的send方法,代码如下所示:@Override public void send( @NonNull String channel, @Nullable ByteBuffer message, @Nullable BinaryMessenger.BinaryReply callback) { Log.v(TAG, "Sending message with callback over channel '" + channel + "'"); int replyId = 0; if (callback != null) { replyId = nextReplyId++; pendingReplies.put(replyId, callback); } if (message == null) { flutterJNI.dispatchEmptyPlatformMessage(channel, replyId); } else { flutterJNI.dispatchPlatformMessage(channel, message, message.position(), replyId); } }尝试一下子线程发送消息,发现会出现崩溃new Thread(new Runnable() { @Override public void run() { nativeChannel.invokeMethod("setNum", a , null); } }).start();崩溃信息如下所示java.lang.RuntimeException: Methods marked with @UiThread must be executed on the main thread. Current thread: Thread-2574 at io.flutter.embedding.engine.FlutterJNI.ensureRunningOnMainThread(FlutterJNI.java:992) at io.flutter.embedding.engine.FlutterJNI.dispatchPlatformMessage(FlutterJNI.java:736) at io.flutter.embedding.engine.dart.DartMessenger.send(DartMessenger.java:72) at io.flutter.embedding.engine.dart.DartExecutor$DefaultBinaryMessenger.send(DartExecutor.java:370) at io.flutter.plugin.common.MethodChannel.invokeMethod(MethodChannel.java:94) at com.ycbjie.ycandroid.channel.MethodChannelActivity.test1000(MethodChannelActivity.java:302) at com.ycbjie.ycandroid.channel.MethodChannelActivity.access$000(MethodChannelActivity.java:46) at com.ycbjie.ycandroid.channel.MethodChannelActivity$1.run(MethodChannelActivity.java:98) at java.lang.Thread.run(Thread.java:818)12.Flutter给NA发送数据从method.invokeMethod('android', map);开始分析@optionalTypeArgs Future<T> _invokeMethod<T>(String method, { bool missingOk, dynamic arguments }) async { assert(method != null); final ByteData result = await binaryMessenger.send( name, codec.encodeMethodCall(MethodCall(method, arguments)), ); return codec.decodeEnvelope(result) as T; }最后定位到_DefaultBinaryMessenger类中的send方法Future<ByteData> _sendPlatformMessage(String channel, ByteData message) { final Completer<ByteData> completer = Completer<ByteData>(); // ui.window is accessed directly instead of using ServicesBinding.instance.window // because this method might be invoked before any binding is initialized. // This issue was reported in #27541. It is not ideal to statically access // ui.window because the Window may be dependency injected elsewhere with // a different instance. However, static access at this location seems to be // the least bad option. ui.window.sendPlatformMessage(channel, message, (ByteData reply) { try { completer.complete(reply); } catch (exception, stack) { FlutterError.reportError(FlutterErrorDetails( exception: exception, stack: stack, library: 'services library', context: ErrorDescription('during a platform message response callback'), )); } }); return completer.future; }13.Channel通信传递稳定性channel传递数据是否会丢失,如何测试呢?可以模拟,Android给flutter发送1000条信息,然后flutter给Android发送1000条信息,接下来看一下如何测试:13.1 Android给flutter发送数据Android给flutter发送数据,代码如下所示int a = 0; private void test1000() { if (nativeChannel!=null){ for (int i=0 ; i<1000 ; i++){ a++; Log.i("测试数据test1000 :", a+""); nativeChannel.invokeMethod("setNum", a , new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { if (result==null){ return; } Log.i("测试数据:",result.toString()); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { Log.i("测试数据异常:",errorMessage); } @Override public void notImplemented() { } }); } } }flutter接收数据并回传数据给Android//接受na端传递过来的方法,并做出响应逻辑处理 method.setMethodCallHandler(nativeCallHandler); // 注册方法,等待被原生通过invokeMethod唤起 Future<dynamic> nativeCallHandler(MethodCall methodCall) async { switch (methodCall.method) { case "setNum": //获取参数 int message = await methodCall.arguments; print("原生android传递过来的参数为------ $message"); return "flutter回调数据:${message.toString()}"; break; } }13.2 查看数据稳定性和及时性Android发送消息日志2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 990 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 991 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 992 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 993 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 994 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 995 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 996 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 997 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 998 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 999 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 1000flutter接收Android发送数据2021-08-26 11:52:39.708 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 992 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 993 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 994 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 995 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 996 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 997 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 998 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 999 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 1000flutter收到消息后,回调给Android数据。Android监听回调数据,打印日志如下2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:600 2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:601 2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:602 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:603 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:604 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:605 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:606 2021-08-26 11:58:03.966 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:607然后再看一波打印日志,如下所示2021-08-26 12:07:09.158 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 1 2021-08-26 12:07:09.237 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:1 2021-08-26 12:07:09.240 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 2 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 3 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:2 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:3 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 4 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:4 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 5 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:5 2021-08-26 12:07:09.242 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 6 2021-08-26 12:07:09.242 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:6 2021-08-26 12:07:09.242 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 7 2021-08-26 12:07:09.242 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:7 2021-08-26 12:07:09.272 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 131 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:131 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 132 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:132 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 133 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:133 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 134 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:134 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 135 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:135 2021-08-26 12:07:09.274 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 136 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:136 2021-08-26 12:07:09.274 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 137 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:137因此查看日志可以得知,传递数据保证了数据的时效性,发送消息和接收消息是一一对应。并没有失败的情况,因此传递数据是稳定的。重点说明,有小伙伴有疑惑,你这遍历1000次,每次传递都是int值,那实际开发中可能传递大json,数据量大的情况会怎样,这个下面会说到……14.onActivityResult如何实现先说一个场景在开发中我们经常会遇到关闭当前页面的同时返回给上一个页面数据的场景,在Android中是通过startActivityForResult和onActivityResult()实现的。而纯Flutter页面之间可以通过在Navigator.of(context).pop()方法中添加参数来实现,那么对于Flutter页面和Android原生页面之间如何在返回上一页时传递数据呢,通过MethodChannel就可以实现。14.1 Flutter页面返回Android原生页面在Flutter端调用原生的返回方法就可以了,首先在Flutter页面添加一个按钮,点击按钮返回原生页面,代码如下:new Padding( padding: const EdgeInsets.only( left: 10.0, top: 10.0, right: 10.0), child: new RaisedButton( textColor: Colors.black, child: new Text('返回上一界面,并携带数据'), onPressed: () { Map<String, dynamic> map = {'message': '我从Flutter页面回来了'}; String result = await method.invokeMethod('goBackWithResult', map); }), ),Android端依然是通过判断methodCall.method的值来执行指定的代码,通过methodCall.argument()获取Flutter传递的参数。nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("goBackWithResult".equals(methodCall.method)) { // 返回上一页,携带数据 Intent backIntent = new Intent(); backIntent.putExtra("message", (String) methodCall.argument("message")); setResult(RESULT_OK, backIntent); finish(); } } });14.2 Android原生页面返回Flutter页面Android原生页面返回Flutter页面这种情况需要原生来调用Flutter代码,和Flutter调用原生方法的步骤是一样的。首先触发flutter页面按钮,从flutter跳转na页面,然后触发na页面返回操作,返回到Flutter页面,并传递数据。首先是flutter页面触发跳转到na页面的代码操作逻辑,代码如下所示//flutter new Padding( padding: const EdgeInsets.only(left: 10.0, top: 10.0, right: 10.0), child: new RaisedButton( textColor: Colors.black, child: new Text('跳转到原生逗比界面,回调结果:$_methodResult1'), onPressed: () { _jumpToNative(); }), ), //na,注意na接收到flutter指令后,na是调用startActivityForResult操作跳转到na的新页面 nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("doubi".equals(methodCall.method)) { //接收来自flutter的指令 //跳转到指定Activity Intent intent = new Intent(MethodChannelActivity.this, MethodResultActivity.class); startActivityForResult(intent,RESULT_OK2); //返回给flutter的参数 result.success("Na收到指令"); } } });然后接下来的一步是,从NA返回到flutter页面,然后再去调用flutter方法。具体操作代码如下所示//na flutter触发打开na的新的页面 public class MethodResultActivity extends AppCompatActivity { @SuppressLint("SetTextI18n") @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_android); TextView tv = findViewById(R.id.tv); tv.setText("flutter页面打开NA页面,测试Android原生页面返回Flutter页面"); tv.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Intent intent = new Intent(); intent.putExtra("message", "我从原生页面回来了"); setResult(RESULT_OK2, intent); finish(); } }); } } // na flutter承载容器的na的原生页面 @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (data != null && resultCode==RESULT_OK2) { // MethodResultActivity返回的数据 String message = data.getStringExtra("message"); Map<String, Object> result = new HashMap<>(); result.put("message", message); // 调用Flutter端定义的方法 nativeChannel.invokeMethod("onActivityResult", result, new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { tvContent.setText("测试内容2:"+result); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { tvContent.setText("测试内容:flutter传递给na数据传递错误2"); } @Override public void notImplemented() { } }); } } //flutter Future<dynamic> handler(MethodCall call) async { switch (call.method) { case 'onActivityResult': // 获取原生页面传递的参数 print(call.arguments['message']); return "你好,这个是从flutter传递过来的数据"; } } flutterChannel.setMethodCallHandler(handler);推荐fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCVideoPlayer
目录介绍01.flutter和原生之间交互02.MethodChanel流程03.MethodChanel使用流程04.MethodChanel代码实践05.EventChannel流程06.EventChannel基本流程07.EventChannel代码实现08.BasicMessageChannel流程09.BasicMessageChannel基本流程10.BasicMessageChannel代码实现11.Channel编解码器说明12.Channel通信可以子线程吗13.Channel通信传递稳定性14.onActivityResult如何实现推荐fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCVideoPlayer01.flutter和原生之间交互1.1 交互简单介绍官方给的通信方式看图片,channel通信方式从底层来看,Flutter和平台端通信的方式是发送异步的二进制消息,该基础通信方式在Flutter端由BinaryMessages来实现, 而在Android端是一个接口BinaryMessenger,其具体实现为FlutterNativeView,在iOS端是一个协议 FlutterBinaryMessenger,FlutterViewController遵守并实现了这个协议。flutter可以与native之间进行通信,帮助我们使用native提供的能力。通信是双向的,我们可以从Native层调用flutter层的dart代码,同时也可以从flutter层调用Native的代码。我们需要使用Platform Channels APIs进行通信,主要包括下面三种:MethodChannel:用于传递方法调用(method invocation)EventChannel:用于事件流的发送(event streams)BasicMessageChannel:用于传递字符串和半结构化的消息,这里什么叫做半结构化?下面会解释……channel通信是异步还是同步的为了保证用户界面在交互过程中的流畅性,无论是从Flutter向Native端发送消息,还是Native向Flutter发送消息都是以异步的形式进行传递的。那为何不使用同步来操作,下面会说到……几种channel应用场景分析MethodChannel使用场景:无论是Flutter端还是Native端都可以通过MethodChannel向对方平台发送两端提前定义好的方法名来调用对方平台相对应的消息处理逻辑并且带回返回值给被调用方。EventChannel的使用场景:更侧重于Native平台主动向Flutter平台,单向给Flutter平台发送消息,Flutter无法返回任何数据给Native端,EventChannel描述是单通的。可以类比Android里面的广播……BasicMessageChannel的使用场景:比如flutter想拍照,拍完照后的图片路径需要传给flutter,照片的路径发送可以使用BasicMessageChannel.Reply回复,也可以使用sendMessage主动再发一次消息。个人认为接收消息并回复消息属于一次通信,所以倾向于使用BasicMessageChannel.Reply。混合开发通常用那种channel只是混合开发通常涉及到两端频繁通信,个人更加倾向使用BasicMessageChannel,不分主客,使用和通信更方便。1.2 核心类重点说明MethodCall方法调用Java层封装,主要是数据类MethodChannel这个主要用户和dart进行方法通信,类MethodCallHandler这个java层处理dart层时间的接口,在通讯协议中属于上层接口,接口BinaryMessageHandlerjava层和dart层通讯的最底层抽象接口,面向二进制数据包,接口DartMessenger最底层用于接收JNI发送过来的数据。实现类DartExecutor配置、引导并开始执行Dart代码。BinaryMessenger的具体实现类FlutterViewNA用来承载flutter的容器viewIncomingMethodCallHandlerBinaryMessageHandler的实现类,用户接收底层发送过来的数据包,然后转发给MethodCallHandler,并对MethodCallHandler 发送过的结果进行打包发送给dart层。实现类FlutterJNIJNI层的封装用于跟底层引擎侧进行通讯02.MethodChannel流程其中最常用的是MethodChanel,MethodChanel的使用与在Android的JNI调用非常类似,但是MethodChanel更加简单,而且相对于JNI的同步调用MethodChanel的调用是异步的:从flutter架构图上可以看到,flutter与native的通信发生在Framework和Engine之间,framewrok内部会将MethodChannel以BinaryMessage的形式与Engine进行数据交换。03.MethodChanel使用流程3.1 flutter调用nativeflutter调用native步骤[native] 使用MethodChannel#setMethodCallHandler注册回调[flutter] 通过MethodChannel#invokeMethod发起异步调用[native] 调用native方法通过Result#success返回Result,出错时返回error[flutter] 收到native返回的Result如图所示3.2 native调用flutternative调用flutter与flutter调用native的顺序完全一致,只是[native]与[flutter]角色反调如图所示NA端使用MethodChannel首先定义Channel名称,需要保证是唯一的,在Flutter端需要使用同样的名称来创建MethodChannel。如果名称不一样,则会导致匹配不上……第一个参数:是messenger,类型是BinaryMessenger,是一个接口,代表消息信使,是消息发送与接收的工具;第二个参数:是name,就是Channel名称,和flutter定义的要一样;第三个参数:是codec,类型是MethodCodec,代表消息的编解码器,如果没有传该参数,默认使用StandardMethodCodec。04.MethodChanel代码实践4.1 native调用flutter定义好了MethodChannel之后调用setMethodCallHandler()方法设置消息处理回调,参数是MethodHandler类型,需要实现它的onMethodCall()方法。onMethodCall()方法有两个参数methodCall和result,methodCall记录了调用的方法信息,包括方法名和参数,result用于方法的返回值,可以通过result.success()方法返回信息给Flutter端。private void createChannel() { nativeChannel = new MethodChannel(binaryMessenger, METHOD_CHANNEL, StandardMethodCodec.INSTANCE); // 注册Handler实现 nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("android".equals(methodCall.method)) { //接收来自flutter的指令 String flutter = methodCall.argument("flutter"); //返回给flutter的参数 result.success("Na收到指令"); } } }); }可以通过invokeMethod方法让NA执行调用flutter方法。那么执行了flutter方法后需要回传数据,这个时候就需要用到Result接口呢,代码如下所示:HashMap<String , String> map = new HashMap<>(); map.put("invokeKey","你好,这个是从NA传递过来的数据"); //nativeChannel.resizeChannelBuffer(100); nativeChannel.invokeMethod("getFlutterResult", map , new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { tvContent.setText("测试内容:"+result); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { tvContent.setText("测试内容:flutter传递给na数据传递错误"); } @Override public void notImplemented() { } });事件接收处理端接收处理回调时onMethodCall(MethodCall call, MethodChannel.Result result)通过methodCall接收事件发送者传递回来的信息,通过Result把处理完的结果发送给事件发送方。通过methodCall.method:来区分不同函数名(方法)名以执行不同的业务逻辑,通过methodCall.hasArgument("key"):判断是否有某个key对应的value通过methodCall.argument("key"):获取key对应的value值通过result.success(object):把处理完的结果返回给事件发送方事件发送端处理事件发送方通过methodChannel.invokeMethod("方法名","要传递的参数")把需要传递的参数传递给事件监听者。 其中方法名:不能为空要传递的参数:可以为空,若不为空则必须为可Json序列化的对象。callback:可以为空,若不为空则表示执行了flutter方法后的回调监听状态4.2 flutter调用nativeFlutter使用MethodChannel在Flutter端同样需要定义一个MethodChannel,使用MethodChannel需要引入services.dart包,Channel名称要和Android端定义的相同。static const method = const MethodChannel('com.ycbjie.android/method');添加监听NA调用flutter方法的监听,flutter代码是setMethodCallHandler方法实现。return则表示flutter回传给NA的数据操作。 method.setMethodCallHandler(nativeCallHandler); // 注册方法,等待被原生通过invokeMethod唤起 Future<dynamic> nativeCallHandler(MethodCall methodCall) async { switch (methodCall.method) { case "getFlutterResult": //获取参数 String paramsFromNative = await methodCall.arguments["invokeKey"]; print("原生android传递过来的参数为------ $paramsFromNative"); return "你好,这个是从flutter回传给NA的数据"; break; } }flutter是如何给NA发送消息的呢,直接调用invokeMethod方法,代码如下所示 Future<Null> _jumpToNativeWithParams1() async { Map<String, String> map = { "flutter": "这是一条来自flutter的参数" }; String result = await method.invokeMethod('android', map); print(result); }05.EventChannel流程EventChannel用于从native向flutter发送通知事件,例如flutter通过其监听Android的重力感应变化等。与MethodChannel不同,EventChannel是native到flutter的单向调用,调用是多播(一对多)的,可以类比成Android的Brodecast广播。06.EventChannel基本流程照例先看一下API使用的基本流程:[native]EventChannel#setStreamHandler注册Handler实现[native]EventChannel初始化结束后,在StreamHandler#onLister回调中获取EventSink引用并保存[flutter]EventChannel#receiveBroadcastStream注册listener,建立监听[native]使用EventSink#sucess发送通知事件[flutter]接受到事件通知[native]通知结束时调用endOfStream结束如图所示07.EventChannel代码实现flutter端创建EventChannel,注册“包名/标识符”的channel名通过StreamSubscription#listen注册listener,其中cancelOnError参数表示遇到错误时是否自动结束监听class _MyHomePageState extends State<MyHomePage> { static const EventChannel _channel = const EventChannel('com.example.eventchannel/interop'); StreamSubscription _streamSubscription; String _platformMessage; void _enableEventReceiver() { _streamSubscription = _channel.receiveBroadcastStream().listen( (dynamic event) { print('Received event: $event'); setState(() { _platformMessage = event; }); }, onError: (dynamic error) { print('Received error: ${error.message}'); }, cancelOnError: true); } void _disableEventReceiver() { if (_streamSubscription != null) { _streamSubscription.cancel(); _streamSubscription = null; } } @override initState() { super.initState(); _enableEventReceiver(); } @override void dispose() { super.dispose(); _disableEventReceiver(); }native(android)端通过EventChannel#setStreamHandler注册Handler实现初始化完成后,获取eventSink引用并保存eventSink发送事件通知通知结束时调用event#endOfStream,此时onCancel会被调用必要时,可通过evnetSink#error发送错误通知,flutter的StreamSubscription#onError会收到通知class MainActivity: FlutterActivity() { private lateinit var channel: EventChannel var eventSink: EventSink? = null override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) channel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.eventchannel/interop") channel.setStreamHandler( object : StreamHandler { override fun onListen(arguments: Any?, events: EventSink) { eventSink = events Log.d("Android", "EventChannel onListen called") Handler().postDelayed({ eventSink?.success("Android") //eventSink?.endOfStream() //eventSink?.error("error code", "error message","error details") }, 500) } override fun onCancel(arguments: Any?) { Log.w("Android", "EventChannel onCancel called") } }) } }08.BasicMessageChannel流程BasicMessageChannel用于在flutter和native互相发送消息,一方给另一方发送消息,收到消息之后给出回复。09.BasicMessageChannel基本流程flutter向native发送消息[flutter]创建BasicMessageChannel[native]通过BasicMessageChannel#MessageHandler注册Handler[flutter]通过BasicMessageChannel#send发送消息[native]BasicMessageChannel#MessageHandler#onMessage中接收消息,然后reply如图所示native向flutter发送消息流程也是一样的,只是将[flutter]与[native]反调如图所示10.BasicMessageChannel代码实现10.1flutter端flutter需要完成以下工作创建BasicMessageChannel通过BasicMessageChannel#send发送消息相对与其他Channel类型的创建,MessageChannel的创建除了channel名以外,还需要指定编码方式:BasicMessageChannel(String name, MessageCodec<T> codec, {BinaryMessenger binaryMessenger})发送的消息会以二进制的形式进行处理,所以要针对不同类型的数进行二进制编码编码类型 消息格式BinaryCodec 发送二进制消息时JSONMessageCodec 发送Json格式消息时StandardMessageCodec 发送基本型数据时StringCodec 发送String类型消息时代码class _MyHomePageState extends State<MyHomePage> { static const _channel = BasicMessageChannel('com.ycbjie.android/basic', StringCodec()); String _platformMessage; void _sendMessage() async { final String reply = await _channel.send('Hello World form Dart'); print(reply); } @override initState() { super.initState(); // Receive messages from platform _channel.setMessageHandler((String message) async { print('Received message = $message'); setState(() => _platformMessage = message); return 'Reply from Dart'; }); // Send message to platform _sendMessage(); }10.2 native(android)端android端完成以下工作:创建BasicMessageChannel通过setHandler注册MessageHandlerMessageHandler#onMessage回调中接收到message后,通过reply进行回复代码class MainActivity: FlutterActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) val channel = BasicMessageChannel( flutterEngine.dartExecutor.binaryMessenger, "com.ycbjie.android/basic", StringCodec.INSTANCE) // Receive messages from Dart channel.setMessageHandler { message, reply -> Log.d("Android", "Received message = $message") reply.reply("Reply from Android") } // Send message to Dart Handler().postDelayed({ channel.send("Hello World from Android") { reply -> Log.d("Android", "$reply") } }, 500) } }11.Channel编解码器说明11.1 什么是消息编解码器什么是消息编解码器在Flutter和平台间进行相互通信了,但是收发的数据都是二进制的,这就需要开发者考虑更多的细节,如字节顺序(大小端)和怎么表示更高级的消息类型,如字符串,map等。因此,Flutter 还提供了消息编解码器(Codec), 用于高级数据类型(字符串,map等)和二进制数据(byte)之间的转换,即消息的序列化和反序列化。消息编解码器种类有哪些MethodCodec:方法传递的编解码器抽象,接口JSONMethodCodec:MethodCodec的实现类,会把数据打包成json结构发送给dart,类StandardMethodCodec:MethodCodec的实现类,会把数据打包成默认格式发送给dart,类11.2 四种消息编解码器类型BinaryCodecMessageCodec的实现类,直接发送二进制数据BinaryCodec是最为简单的一种Codec,因为其返回值类型和入参的类型相同,均为二进制格式(Android中为ByteBuffer,iOS中为NSData)。实际上,BinaryCodec在编解码过程中什么都没做,只是原封不动将二进制数据消息返回而已。或许你会因此觉得BinaryCodec没有意义,但是在某些情况下它非常有用,比如使用BinaryCodec可以使传递内存数据块时在编解码阶段免于内存拷贝。StringCodecMessageCodec的实现类,负责解码和编码String类型的消息使用 UTF-8 编码格式对字符串数据进行编解码,在Android平台转换为 java.util.String 类型JSONMessageCodecMessageCodec的实现类,负责解码和编码Json类型的消息JSONMessageCodec用于处理 JSON 数据类型(字符串型,数字型,布尔型,null,只包含这些类型的数组,和key为string类型,value为这些类型的map),在编码过程中,数据会被转换为JSON字符串,然后在使用 UTF-8 格式转换为字节型。StandardMessageCodecMessageCodec的实现类,负责解码和编码默认类型的消息StandardMessageCodec 可以认为是 JSONMessageCodec 的升级版,能够处理的数据类型要比 JSONMessageCodec 更普遍一些,且在处理 int 型数据时,会根据 int 数据的大小来转为平台端的32位类型(int)或者是64位类型(long),StandardMessageCodec 也是 Flutter Platform channel 的默认编解码器11.3 编码器的源码分析下首先看下MessageCodecabstract class MessageCodec<T> { ByteData encodeMessage(T message); T decodeMessage(ByteData message); }11.4 看StandardMessageCodecStandardMessageCodec稍微复杂StandardMessageCodec在写入数据的时候,显示写入这个数据的类型值定义,然后在写入其对应的具体值,什么意思呢?查看一下如何写入指定类型的值,代码如下所示: protected void writeValue(ByteArrayOutputStream stream, Object value) { if (value == null || value.equals(null)) { stream.write(NULL); } else if (value == Boolean.TRUE) { stream.write(TRUE); } else if (value == Boolean.FALSE) { stream.write(FALSE); } else if (value instanceof Number) { if (value instanceof Integer || value instanceof Short || value instanceof Byte) { stream.write(INT); writeInt(stream, ((Number) value).intValue()); } else if (value instanceof Long) { stream.write(LONG); writeLong(stream, (long) value); } else if (value instanceof Float || value instanceof Double) { stream.write(DOUBLE); writeAlignment(stream, 8); writeDouble(stream, ((Number) value).doubleValue()); } else if (value instanceof BigInteger) { stream.write(BIGINT); writeBytes(stream, ((BigInteger) value).toString(16).getBytes(UTF8)); } else { throw new IllegalArgumentException("Unsupported Number type: " + value.getClass()); } } else if (value instanceof String) { stream.write(STRING); writeBytes(stream, ((String) value).getBytes(UTF8)); } else if (value instanceof byte[]) { stream.write(BYTE_ARRAY); writeBytes(stream, (byte[]) value); } else if (value instanceof int[]) { stream.write(INT_ARRAY); final int[] array = (int[]) value; writeSize(stream, array.length); writeAlignment(stream, 4); for (final int n : array) { writeInt(stream, n); } } else if (value instanceof long[]) { stream.write(LONG_ARRAY); final long[] array = (long[]) value; writeSize(stream, array.length); writeAlignment(stream, 8); for (final long n : array) { writeLong(stream, n); } } else if (value instanceof double[]) { stream.write(DOUBLE_ARRAY); final double[] array = (double[]) value; writeSize(stream, array.length); writeAlignment(stream, 8); for (final double d : array) { writeDouble(stream, d); } } else if (value instanceof List) { stream.write(LIST); final List<?> list = (List) value; writeSize(stream, list.size()); for (final Object o : list) { writeValue(stream, o); } } else if (value instanceof Map) { stream.write(MAP); final Map<?, ?> map = (Map) value; writeSize(stream, map.size()); for (final Entry<?, ?> entry : map.entrySet()) { writeValue(stream, entry.getKey()); writeValue(stream, entry.getValue()); } } else { throw new IllegalArgumentException("Unsupported value: " + value); } }查看一下如何读取指定类型的值,代码如下所示: protected Object readValueOfType(byte type, ByteBuffer buffer) { final Object result; switch (type) { case NULL: result = null; break; case TRUE: result = true; break; case FALSE: result = false; break; case INT: result = buffer.getInt(); break; case LONG: result = buffer.getLong(); break; case BIGINT: { final byte[] hex = readBytes(buffer); result = new BigInteger(new String(hex, UTF8), 16); break; } case DOUBLE: readAlignment(buffer, 8); result = buffer.getDouble(); break; case STRING: { final byte[] bytes = readBytes(buffer); result = new String(bytes, UTF8); break; } case BYTE_ARRAY: { result = readBytes(buffer); break; } case INT_ARRAY: { final int length = readSize(buffer); final int[] array = new int[length]; readAlignment(buffer, 4); buffer.asIntBuffer().get(array); result = array; buffer.position(buffer.position() + 4 * length); break; } case LONG_ARRAY: { final int length = readSize(buffer); final long[] array = new long[length]; readAlignment(buffer, 8); buffer.asLongBuffer().get(array); result = array; buffer.position(buffer.position() + 8 * length); break; } case DOUBLE_ARRAY: { final int length = readSize(buffer); final double[] array = new double[length]; readAlignment(buffer, 8); buffer.asDoubleBuffer().get(array); result = array; buffer.position(buffer.position() + 8 * length); break; } case LIST: { final int size = readSize(buffer); final List<Object> list = new ArrayList<>(size); for (int i = 0; i < size; i++) { list.add(readValue(buffer)); } result = list; break; } case MAP: { final int size = readSize(buffer); final Map<Object, Object> map = new HashMap<>(); for (int i = 0; i < size; i++) { map.put(readValue(buffer), readValue(buffer)); } result = map; break; } default: throw new IllegalArgumentException("Message corrupted"); } return result; }11.5 如何选择合适编解码器编解码的实现类并不复杂可以先了解一下这个比较能更好的理解数据传递,其实不关java上层使用那种方式,最终传递给底层数据都是固定格式,约定统一的数据格式双方才能识别出来,正常的来说用默认的编解码格式就可以了。关于四种解码器使用场景BinaryCodec暂未找到使用的场景StringCodec适用发送单一的字符串数据,数据量单一的情况,比如LifecycleChannelpublic void appIsInactive() { Log.v(TAG, "Sending AppLifecycleState.inactive message."); channel.send("AppLifecycleState.inactive"); }JSONMessageCodec适用数据量比较复杂的情况,比如有携带多个数据字段的传递,比如KeyEventChannelpublic void keyDown(@NonNull FlutterKeyEvent keyEvent) { Map<String, Object> message = new HashMap<>(); message.put("type", "keydown"); message.put("keymap", "android"); encodeKeyEvent(keyEvent, message); channel.send(message); }StandardMessageCodec默认的数据编解码,绝大多数的情况下使用默认的就可以了。比如:MethodChannel,EventChannel12.Channel通信可以子线程吗12.1 Android发送通信信息首先看一下Android发送通信信息,主要分析入口是:nativeChannel.invokeMethod("setNum", a , null);public void invokeMethod(String method, @Nullable Object arguments, Result callback) { messenger.send( name, codec.encodeMethodCall(new MethodCall(method, arguments)), callback == null ? null : new IncomingResultHandler(callback)); }最终定位找到DartMessenger类的send方法,代码如下所示:@Override public void send( @NonNull String channel, @Nullable ByteBuffer message, @Nullable BinaryMessenger.BinaryReply callback) { Log.v(TAG, "Sending message with callback over channel '" + channel + "'"); int replyId = 0; if (callback != null) { replyId = nextReplyId++; pendingReplies.put(replyId, callback); } if (message == null) { flutterJNI.dispatchEmptyPlatformMessage(channel, replyId); } else { flutterJNI.dispatchPlatformMessage(channel, message, message.position(), replyId); } }尝试一下子线程发送消息,发现会出现崩溃new Thread(new Runnable() { @Override public void run() { nativeChannel.invokeMethod("setNum", a , null); } }).start();崩溃信息如下所示java.lang.RuntimeException: Methods marked with @UiThread must be executed on the main thread. Current thread: Thread-2574 at io.flutter.embedding.engine.FlutterJNI.ensureRunningOnMainThread(FlutterJNI.java:992) at io.flutter.embedding.engine.FlutterJNI.dispatchPlatformMessage(FlutterJNI.java:736) at io.flutter.embedding.engine.dart.DartMessenger.send(DartMessenger.java:72) at io.flutter.embedding.engine.dart.DartExecutor$DefaultBinaryMessenger.send(DartExecutor.java:370) at io.flutter.plugin.common.MethodChannel.invokeMethod(MethodChannel.java:94) at com.ycbjie.ycandroid.channel.MethodChannelActivity.test1000(MethodChannelActivity.java:302) at com.ycbjie.ycandroid.channel.MethodChannelActivity.access$000(MethodChannelActivity.java:46) at com.ycbjie.ycandroid.channel.MethodChannelActivity$1.run(MethodChannelActivity.java:98) at java.lang.Thread.run(Thread.java:818)12.Flutter给NA发送数据从method.invokeMethod('android', map);开始分析 @optionalTypeArgs Future<T> _invokeMethod<T>(String method, { bool missingOk, dynamic arguments }) async { assert(method != null); final ByteData result = await binaryMessenger.send( name, codec.encodeMethodCall(MethodCall(method, arguments)), ); return codec.decodeEnvelope(result) as T; }最后定位到_DefaultBinaryMessenger类中的send方法 Future<ByteData> _sendPlatformMessage(String channel, ByteData message) { final Completer<ByteData> completer = Completer<ByteData>(); // ui.window is accessed directly instead of using ServicesBinding.instance.window // because this method might be invoked before any binding is initialized. // This issue was reported in #27541. It is not ideal to statically access // ui.window because the Window may be dependency injected elsewhere with // a different instance. However, static access at this location seems to be // the least bad option. ui.window.sendPlatformMessage(channel, message, (ByteData reply) { try { completer.complete(reply); } catch (exception, stack) { FlutterError.reportError(FlutterErrorDetails( exception: exception, stack: stack, library: 'services library', context: ErrorDescription('during a platform message response callback'), )); } }); return completer.future; }13.Channel通信传递稳定性channel传递数据是否会丢失,如何测试呢?可以模拟,Android给flutter发送1000条信息,然后flutter给Android发送1000条信息,接下来看一下如何测试:13.1 Android给flutter发送数据Android给flutter发送数据,代码如下所示int a = 0; private void test1000() { if (nativeChannel!=null){ for (int i=0 ; i<1000 ; i++){ a++; Log.i("测试数据test1000 :", a+""); nativeChannel.invokeMethod("setNum", a , new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { if (result==null){ return; } Log.i("测试数据:",result.toString()); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { Log.i("测试数据异常:",errorMessage); } @Override public void notImplemented() { } }); } } }flutter接收数据并回传数据给Android //接受na端传递过来的方法,并做出响应逻辑处理 method.setMethodCallHandler(nativeCallHandler); // 注册方法,等待被原生通过invokeMethod唤起 Future<dynamic> nativeCallHandler(MethodCall methodCall) async { switch (methodCall.method) { case "setNum": //获取参数 int message = await methodCall.arguments; print("原生android传递过来的参数为------ $message"); return "flutter回调数据:${message.toString()}"; break; } }13.2 查看数据稳定性和及时性Android发送消息日志2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 990 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 991 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 992 2021-08-26 11:58:03.837 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 993 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 994 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 995 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 996 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 997 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 998 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 999 2021-08-26 11:58:03.838 23106-23106/com.ycbjie.ychybrid I/测试数据test1000:: 1000flutter接收Android发送数据2021-08-26 11:52:39.708 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 992 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 993 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 994 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 995 2021-08-26 11:52:39.709 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 996 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 997 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 998 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 999 2021-08-26 11:52:39.710 23106-23627/com.ycbjie.ychybrid I/flutter: 原生android传递过来的参数为------ 1000flutter收到消息后,回调给Android数据。Android监听回调数据,打印日志如下2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:600 2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:601 2021-08-26 11:58:03.964 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:602 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:603 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:604 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:605 2021-08-26 11:58:03.965 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:606 2021-08-26 11:58:03.966 23106-23106/com.ycbjie.ychybrid I/测试数据:: flutter回调数据:607然后再看一波打印日志,如下所示2021-08-26 12:07:09.158 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 1 2021-08-26 12:07:09.237 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:1 2021-08-26 12:07:09.240 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 2 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 3 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:2 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:3 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 4 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:4 2021-08-26 12:07:09.241 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 5 2021-08-26 12:07:09.241 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:5 2021-08-26 12:07:09.242 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 6 2021-08-26 12:07:09.242 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:6 2021-08-26 12:07:09.242 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 7 2021-08-26 12:07:09.242 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:7 2021-08-26 12:07:09.272 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 131 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:131 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 132 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:132 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 133 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:133 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 134 2021-08-26 12:07:09.273 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:134 2021-08-26 12:07:09.273 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 135 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:135 2021-08-26 12:07:09.274 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 136 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:136 2021-08-26 12:07:09.274 24237-24990/com.ycbjie.ychybrid I/flutter: 测试数据,flutter接收NA数据: 137 2021-08-26 12:07:09.274 24237-24237/com.ycbjie.ychybrid I/测试数据,NA接收flutter回调:: flutter回调数据:137因此查看日志可以得知,传递数据保证了数据的时效性,发送消息和接收消息是一一对应。并没有失败的情况,因此传递数据是稳定的。重点说明,有小伙伴有疑惑,你这遍历1000次,每次传递都是int值,那实际开发中可能传递大json,数据量大的情况会怎样,这个下面会说到……14.onActivityResult如何实现先说一个场景在开发中我们经常会遇到关闭当前页面的同时返回给上一个页面数据的场景,在Android中是通过startActivityForResult和onActivityResult()实现的。而纯Flutter页面之间可以通过在Navigator.of(context).pop()方法中添加参数来实现,那么对于Flutter页面和Android原生页面之间如何在返回上一页时传递数据呢,通过MethodChannel就可以实现。14.1 Flutter页面返回Android原生页面在Flutter端调用原生的返回方法就可以了,首先在Flutter页面添加一个按钮,点击按钮返回原生页面,代码如下:new Padding( padding: const EdgeInsets.only( left: 10.0, top: 10.0, right: 10.0), child: new RaisedButton( textColor: Colors.black, child: new Text('返回上一界面,并携带数据'), onPressed: () { Map<String, dynamic> map = {'message': '我从Flutter页面回来了'}; String result = await method.invokeMethod('goBackWithResult', map); }), ),Android端依然是通过判断methodCall.method的值来执行指定的代码,通过methodCall.argument()获取Flutter传递的参数。nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("goBackWithResult".equals(methodCall.method)) { // 返回上一页,携带数据 Intent backIntent = new Intent(); backIntent.putExtra("message", (String) methodCall.argument("message")); setResult(RESULT_OK, backIntent); finish(); } } });14.2 Android原生页面返回Flutter页面Android原生页面返回Flutter页面这种情况需要原生来调用Flutter代码,和Flutter调用原生方法的步骤是一样的。首先触发flutter页面按钮,从flutter跳转na页面,然后触发na页面返回操作,返回到Flutter页面,并传递数据。首先是flutter页面触发跳转到na页面的代码操作逻辑,代码如下所示//flutter new Padding( padding: const EdgeInsets.only(left: 10.0, top: 10.0, right: 10.0), child: new RaisedButton( textColor: Colors.black, child: new Text('跳转到原生逗比界面,回调结果:$_methodResult1'), onPressed: () { _jumpToNative(); }), ), //na,注意na接收到flutter指令后,na是调用startActivityForResult操作跳转到na的新页面 nativeChannel.setMethodCallHandler(new MethodChannel.MethodCallHandler() { @Override public void onMethodCall(@NonNull MethodCall methodCall, @NonNull MethodChannel.Result result) { if ("doubi".equals(methodCall.method)) { //接收来自flutter的指令 //跳转到指定Activity Intent intent = new Intent(MethodChannelActivity.this, MethodResultActivity.class); startActivityForResult(intent,RESULT_OK2); //返回给flutter的参数 result.success("Na收到指令"); } } });然后接下来的一步是,从NA返回到flutter页面,然后再去调用flutter方法。具体操作代码如下所示//na flutter触发打开na的新的页面 public class MethodResultActivity extends AppCompatActivity { @SuppressLint("SetTextI18n") @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_android); TextView tv = findViewById(R.id.tv); tv.setText("flutter页面打开NA页面,测试Android原生页面返回Flutter页面"); tv.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Intent intent = new Intent(); intent.putExtra("message", "我从原生页面回来了"); setResult(RESULT_OK2, intent); finish(); } }); } } // na flutter承载容器的na的原生页面 @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (data != null && resultCode==RESULT_OK2) { // MethodResultActivity返回的数据 String message = data.getStringExtra("message"); Map<String, Object> result = new HashMap<>(); result.put("message", message); // 调用Flutter端定义的方法 nativeChannel.invokeMethod("onActivityResult", result, new MethodChannel.Result() { @SuppressLint("SetTextI18n") @Override public void success(@Nullable Object result) { tvContent.setText("测试内容2:"+result); } @SuppressLint("SetTextI18n") @Override public void error(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { tvContent.setText("测试内容:flutter传递给na数据传递错误2"); } @Override public void notImplemented() { } }); } } //flutter Future<dynamic> handler(MethodCall call) async { switch (call.method) { case 'onActivityResult': // 获取原生页面传递的参数 print(call.arguments['message']); return "你好,这个是从flutter传递过来的数据"; } } flutterChannel.setMethodCallHandler(handler);推荐fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCVideoPlayer
目录介绍01.什么是状态管理02.状态管理方案分类03.状态管理使用场景04.Widget管理自己的状态05.Widget管理子Widget状态06.简单混合管理状态07.全局状态如何管理08.Provider使用方法09.订阅监听修改状态推荐fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCHybridFlutter01.什么是状态管理响应式的编程框架中都会有一个永恒的主题——“状态(State)管理”在Flutter中,想一个问题,StatefulWidget的状态应该被谁管理?Widget本身?父Widget?都会?还是另一个对象?答案是取决于实际情况!以下是管理状态的最常见的方法:Widget管理自己的状态。Widget管理子Widget状态。混合管理(父Widget和子Widget都管理状态)。不同模块的状态管理。如何决定使用哪种管理方法?下面给出的一些原则可以帮助你做决定:如果状态是用户数据,如复选框的选中状态、滑块的位置,则该状态最好由父Widget管理。如果状态是有关界面外观效果的,例如颜色、动画,那么状态最好由Widget本身来管理。如果某一个状态是不同Widget共享的则最好由它们共同的父Widget管理。如果是多个模块需要公用一个状态,那么该怎么处理呢,那可以用Provider。如果修改了某一个属性,需要刷新多个地方数据。比如修改用户城市id数据,那么则刷新首页n处的接口数据,这个时候可以用订阅监听修改状态02.状态管理方案分类setState状态管理优点:简单场景下特别适用,逻辑简单,易懂易实现所见即所得,效率比较高缺点逻辑与视图耦合严重,复杂逻辑下可维护性很差数据传输基于依赖传递,层级较深情况下不易维护,可读性差InheritedWidget状态管理优点方便数据传输,可以基于InheritedWidget达到逻辑和视图解耦的效果flutter内嵌类,基础且稳定,无代码侵入缺点属于比较基础的类,友好性不如封装的第三方库对于性能需要额外注意,刷新范围如果过大会影响性能Provider状态管理优点功能完善,涵盖了ScopedModel和InheritedWidget的所有功能数据逻辑完美融入了widget树中,代码结构清晰,可以管理局部状态和全局状态解决了多model和资源回收的问题对不同场景下使用的provider做了优化和区分支持异步状态管理和provider依赖注入缺点使用不当可能会造成性能问题(大context引起的rebuild)局部状态之前的数据同步不支持订阅监听修改状态有两种:一种是bus事件通知(是一种订阅+观察),另一个是接口注册回调。接口回调:由于使用了回调函数原理,因此数据传递实时性非常高,相当于直接调用,一般用在功能模块上。bus事件:组件之间的交互,很大程度上降低了它们之间的耦合,使得代码更加简洁,耦合性更低,提升我们的代码质量。03.状态管理使用场景setState状态管理适合Widget管理自己的状态,这种很常见,调用setState刷新自己widget改变状态。适合Widget管理子Widget状态,这种也比较常见。不过这种关联性比较强。04.Widget管理自己的状态_TapboxAState 类:管理TapboxA的状态。定义_active:确定盒子的当前颜色的布尔值。定义_handleTap()函数,该函数在点击该盒子时更新_active,并调用setState()更新UI。实现widget的所有交互式行为。代码如下// TapboxA 管理自身状态. //------------------------- TapboxA ---------------------------------- class TapboxA extends StatefulWidget { TapboxA({Key key}) : super(key: key); @override _TapboxAState createState() => new _TapboxAState(); } class _TapboxAState extends State<TapboxA> { bool _active = false; void _handleTap() { setState(() { _active = !_active; }); } Widget build(BuildContext context) { return new GestureDetector( onTap: _handleTap, child: new Container( child: new Center( child: new Text( _active ? 'Active' : 'Inactive', style: new TextStyle(fontSize: 32.0, color: Colors.white), ), ), width: 200.0, height: 200.0, decoration: new BoxDecoration( color: _active ? Colors.lightGreen[700] : Colors.grey[600], ), ), ); } }05.Widget管理子Widget状态先看一下下面这些是什么typedef ValueChanged<T> = void Function(T value);对于父Widget来说,管理状态并告诉其子Widget何时更新通常是比较好的方式。例如,IconButton是一个图标按钮,但它是一个无状态的Widget,因为我们认为父Widget需要知道该按钮是否被点击来采取相应的处理。在以下示例中,TapboxB通过回调将其状态导出到其父组件,状态由父组件管理,因此它的父组件为StatefulWidget。ParentWidgetState 类:为TapboxB 管理_active状态。实现_handleTapboxChanged(),当盒子被点击时调用的方法。当状态改变时,调用setState()更新UI。TapboxB 类:继承StatelessWidget类,因为所有状态都由其父组件处理。当检测到点击时,它会通知父组件。// ParentWidget 为 TapboxB 管理状态. class ParentWidget extends StatefulWidget { @override _ParentWidgetState createState() => new _ParentWidgetState(); } class _ParentWidgetState extends State<ParentWidget> { bool _active = false; void _handleTapboxChanged(bool newValue) { setState(() { _active = newValue; }); } @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text("Widget管理子Widget状态"), ), body: new ListView( children: [ new Text("Widget管理子Widget状态"), new TapboxB( active: _active, onChanged: _handleTapboxChanged, ), ], ), ); } } //------------------------- TapboxB ---------------------------------- class TapboxB extends StatefulWidget{ final bool active; final ValueChanged<bool> onChanged; TapboxB({Key key , this.active : false ,@required this.onChanged }); @override State<StatefulWidget> createState() { return new TabboxBState(); } } class TabboxBState extends State<TapboxB>{ void _handleTap() { widget.onChanged(!widget.active); } @override Widget build(BuildContext context) { return new GestureDetector( onTap: _handleTap, child: new Container( child: new Center( child: new Text( widget.active ? 'Active' : 'Inactive', ), ), width: 100, height: 100, decoration: new BoxDecoration( color: widget.active ? Colors.lightGreen[700] : Colors.grey[850], ), ), ); } }06.简单混合管理状态对于一些组件来说,混合管理的方式会非常有用。在这种情况下,组件自身管理一些内部状态,而父组件管理一些其他外部状态。在下面TapboxC示例中手指按下时,盒子的周围会出现一个深绿色的边框,抬起时,边框消失。点击完成后,盒子的颜色改变。TapboxC将其_active状态导出到其父组件中,但在内部管理其_highlight状态。这个例子有两个状态对象_ParentWidgetState和_TapboxCState。_ParentWidgetStateC 类:管理_active 状态。实现 _handleTapboxChanged() ,当盒子被点击时调用。当点击盒子并且_active状态改变时调用setState()更新UI。_TapboxCState 对象:管理_highlight 状态。GestureDetector监听所有tap事件。当用户点下时,它添加高亮(深绿色边框);当用户释放时,会移除高亮。当按下、抬起、或者取消点击时更新_highlight状态,调用setState()更新UI。当点击时,将状态的改变传递给父组件。//---------------------------- ParentWidget ---------------------------- class ParentWidgetC extends StatefulWidget { @override _ParentWidgetCState createState() => new _ParentWidgetCState(); } class _ParentWidgetCState extends State<ParentWidgetC> { bool _active = false; void _handleTapboxChanged(bool newValue) { setState(() { _active = newValue; }); } @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text("简单混合管理状态"), ), body: new Container( child: new ListView( children: [ new Text("_ParentWidgetCState状态管理"), new Padding(padding: EdgeInsets.all(10)), new Text( _active ? 'Active' : 'Inactive', ), new Padding(padding: EdgeInsets.all(10)), new Text("_TapboxCState状态管理"), new TapboxC( active: _active, onChanged: _handleTapboxChanged, ) ], ), ), ); } } //----------------------------- TapboxC ------------------------------ class TapboxC extends StatefulWidget { TapboxC({Key key, this.active: false, @required this.onChanged}) : super(key: key); final bool active; final ValueChanged<bool> onChanged; @override _TapboxCState createState() => new _TapboxCState(); } class _TapboxCState extends State<TapboxC> { bool _highlight = false; void _handleTapDown(TapDownDetails details) { setState(() { _highlight = true; }); } void _handleTapUp(TapUpDetails details) { setState(() { _highlight = false; }); } void _handleTapCancel() { setState(() { _highlight = false; }); } void _handleTap() { widget.onChanged(!widget.active); } @override Widget build(BuildContext context) { // 在按下时添加绿色边框,当抬起时,取消高亮 return new GestureDetector( onTapDown: _handleTapDown, // 处理按下事件 onTapUp: _handleTapUp, // 处理抬起事件 onTap: _handleTap, onTapCancel: _handleTapCancel, child: new Container( child: new Center( child: new Text(widget.active ? 'Active' : 'Inactive', style: new TextStyle(fontSize: 32.0, color: Colors.white)), ), width: 200.0, height: 200.0, decoration: new BoxDecoration( color: widget.active ? Colors.lightGreen[700] : Colors.grey[600], border: _highlight ? new Border.all( color: Colors.teal[700], width: 10.0, ) : null, ), ), ); } }07.全局状态如何管理当应用中需要一些跨组件(包括跨路由)的状态需要同步时,上面介绍的方法便很难胜任了。比如,我们有一个设置页,里面可以设置应用的语言,我们为了让设置实时生效,我们期望在语言状态发生改变时,APP中依赖应用语言的组件能够重新build一下,但这些依赖应用语言的组件和设置页并不在一起,所以这种情况用上面的方法很难管理。这时,正确的做法是通过一个全局状态管理器来处理这种相距较远的组件之间的通信。目前主要有两种办法:1.实现一个全局的事件总线,将语言状态改变对应为一个事件,然后在APP中依赖应用语言的组件的initState 方法中订阅语言改变的事件。当用户在设置页切换语言后,我们发布语言改变事件,而订阅了此事件的组件就会收到通知,收到通知后调用setState(...)方法重新build一下自身即可。2.使用一些专门用于状态管理的包,如Provider、Redux,读者可以在pub上查看其详细信息。举一个简答的案例来实践本实例中,使用Provider包来实现跨组件状态共享,因此我们需要定义相关的Provider。需要共享的状态有登录用户信息、APP主题信息、APP语言信息。由于这些信息改变后都要立即通知其它依赖的该信息的Widget更新,所以我们应该使用ChangeNotifierProvider,另外,这些信息改变后都是需要更新Profile信息并进行持久化的。综上所述,我们可以定义一个ProfileChangeNotifier基类,然后让需要共享的Model继承自该类即可,ProfileChangeNotifier定义如下:class ProfileChangeNotifier extends ChangeNotifier { Profile get _profile => Global.profile; @override void notifyListeners() { Global.saveProfile(); //保存Profile变更 super.notifyListeners(); //通知依赖的Widget更新 } }用户状态用户状态在登录状态发生变化时更新、通知其依赖项,我们定义如下:class UserModel extends ProfileChangeNotifier { User get user => _profile.user; // APP是否登录(如果有用户信息,则证明登录过) bool get isLogin => user != null; //用户信息发生变化,更新用户信息并通知依赖它的子孙Widgets更新 set user(User user) { if (user?.login != _profile.user?.login) { _profile.lastLogin = _profile.user?.login; _profile.user = user; notifyListeners(); } } }08.Provider使用方法8.1 正确地初始化 Provider如下所示,create是必须要传递的参数ChangeNotifierProvider( create: (_) => MyModel(), child: ... )实际开发中如何应用builder: (BuildContext context, Widget child) { return MultiProvider(providers: [ ChangeNotifierProvider(create: (context) => BusinessPattern()), ]); },然后看一下BusinessPattern是什么?class BusinessPattern extends ChangeNotifier { PatternState currentState = PatternState.none; void updateBusinessPatternState(PatternState state) { if (currentState.index != state.index) { LogUtils.d('当前模式:$currentState'); LogUtils.d('更新模式:$state'); currentState = state; notifyListeners(); } } }8.2 如何获取Provider取值一种是 Provider.of(context) 比如:Widget build(BuildContext context) { final text = Provider.of<String>(context); return Container(child: Text(text)); }遇到的问题:由于 Provider 会监听 Value 的变化而更新整个 context 上下文,因此如果 build 方法返回的 Widget 过大过于复杂的话,刷新的成本是非常高的。那么我们该如何进一步控制 Widget 的更新范围呢?解决办法:一个办法是将真正需要更新的 Widget 封装成一个独立的 Widget,将取值方法放到该 Widget 内部。Widget build(BuildContext context) { return Container(child: MyText()); } class MyText extends StatelessWidget { @override Widget build(BuildContext context) { final text = Provider.of<String>(context); return Text(text); } }Consumer 是 Provider 的另一种取值方式Consumer 可以直接拿到 context 连带 Value 一并传作为参数传递给 builder ,使用无疑更加方便和直观,大大降低了开发人员对于控制刷新范围的工作成本。Widget getWidget2(BuildContext context) { return Consumer<BusinessPattern>(builder: (context, businessModel, child) { switch (businessModel.currentState) { case PatternState.none: return Text("无模式"); break; case PatternState.normal: return Text("正常模式"); break; case PatternState.small: return Text("小屏模式"); break; case PatternState.overview: return Text("全屏模式"); break; default: return Text("其他模式"); return SizedBox(); } }); }Selector 是 Provider 的另一种取值方式Selector 是 3.1 推出的功能,目的是更近一步的控制 Widget 的更新范围,将监听刷新的范围控制到最小selector:是一个 Function,传入 Value ,要求我们返回 Value 中具体使用到的属性。shouldRebuild:这个 Function 会传入两个值,其中一个为之前保持的旧值,以及此次由 selector 返回的新值,我们就是通过这个参数控制是否需要刷新 builder 内的 Widget。如果不实现 shouldRebuild ,默认会对 pre 和 next 进行深比较(deeply compares)。如果不相同,则返回 true。builder:返回 Widget 的地方,第二个参数 定义的参数,就是我们刚才 selector 中返回的 参数。Widget getWidget4(BuildContext context) { return Selector<BusinessPattern, PatternState>( selector: (context, businessPattern) => businessPattern.currentState, builder: (context, state, child) { switch (state) { case PatternState.none: return Text("无模式"); break; case PatternState.normal: return Text("正常模式"); break; case PatternState.small: return Text("小屏模式"); break; case PatternState.overview: return Text("全屏模式"); break; default: return Text("其他模式"); return SizedBox(); } } );8.3 修改Provider状态如何调用修改状态管理BusinessPatternService _patternService = serviceLocator<BusinessPatternService>(); //修改状态 _patternService.nonePattern(); _patternService.normalPattern();然后看一下normalPattern的具体实现代码class BusinessPatternServiceImpl extends BusinessPatternService { final BuildContext context; BusinessPatternServiceImpl(this.context); PatternState get currentPatternState => _getBusinessPatternState(context).currentState; BusinessPattern _getBusinessPatternState(BuildContext context) { return Provider.of<BusinessPattern>(context, listen: false); } @override void nonePattern() { BusinessPattern _patternState = _getBusinessPatternState(context); _patternState.updateBusinessPatternState(PatternState.none); } @override void normalPattern() { BusinessPattern _patternState = _getBusinessPatternState(context); _patternState.updateBusinessPatternState(PatternState.normal); } }8.4 关于Provider刷新状态发生变化后,widget只会重新build,而不会重新创建(重用机制跟key有关,如果key发生变化widget就会重新生成)09.订阅监听修改状态首先定义抽象类。还需要写上具体的实现类typedef LocationDataChangedFunction = void Function(double); abstract class LocationListener { /// 注册数据变化的回调 void registerDataChangedFunction(LocationDataChangedFunction function); /// 移除数据变化的回调 void unregisterDataChangedFunction(LocationDataChangedFunction function); /// 更新数据的变化 void locationDataChangedCallback(double angle); } class LocationServiceCenterImpl extends LocationListener { List<LocationDataChangedFunction> _locationDataChangedFunction = List(); @override void locationDataChangedCallback(double angle) { _locationDataChangedFunction.forEach((function) { function.call(angle); }); } @override void registerDataChangedFunction(LocationDataChangedFunction function) { _locationDataChangedFunction.add(function); } @override void unregisterDataChangedFunction(LocationDataChangedFunction function) { _locationDataChangedFunction.remove(function); } }那么如何使用呢?在需要用的页面添加接口回调监听_locationListener.registerDataChangedFunction(_onDataChange); void _onDataChange(double p1) { //监听回调处理 }那么如何发送事件,这个时候LocationListener _locationListener = locationService(); _locationListener.locationDataChangedCallback(520.0);fluter Utils 工具类库:https://github.com/yangchong211/YCFlutterUtilsflutter 混合项目代码案例:https://github.com/yangchong211/YCHybridFlutter
04.视频播放器通用架构实践 目录介绍 01.视频播放器的痛点 02.业务需求的目标 03.该播放器框架特点 04.播放器内核封装 05.播放器UI层封装 06.如何简单使用 07.如何自定义播放器 08.该案例的拓展性分享 09.关于视频缓存方案 10.如何监控视频埋点 11.待实现的需求分析 12.一些细节上优化 13.参考案例和博客记录 00.视频播放器通用框架 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 该播放器整体架构:播放器内核(自由切换) + 视频播放器 + 边播边缓存 + 高度定制播放器UI视图层 项目地址:https://github.com/yangchong211/YCVideoPlayer 关于视频播放器整体功能介绍文档:https://juejin.im/post/6883457444752654343 关于视频播放器通用架构实践: 01.视频播放器的痛点 播放器内核难以切换 不同的视频播放器内核,由于api不一样,所以难以切换操作。要是想兼容内核切换,就必须自己制定一个视频接口+实现类的播放器 播放器内核和UI层耦合 也就是说视频player和ui操作柔和到了一起,尤其是两者之间的交互。比如播放中需要更新UI进度条,播放异常需要显示异常UI,都比较难处理播放器状态变化更新UI操作 UI难以自定义或者修改麻烦 比如常见的视频播放器,会把视频各种视图写到xml中,这种方式在后期代码会很大,而且改动一个小的布局,则会影响大。这样到后期往往只敢加代码,而不敢删除代码…… 有时候难以适应新的场景,比如添加一个播放广告,老师开课,或者视频引导业务需求,则需要到播放器中写一堆业务代码。迭代到后期,违背了开闭原则,视频播放器需要做到和业务分离 视频播放器结构不清晰 这个是指该视频播放器能否看了文档后快速上手,知道封装的大概流程。方便后期他人修改和维护,因此需要将视频播放器功能分离。比如切换内核+视频播放器(player+controller+view) 播放器播放和业务耦合 比如多个app共用一个视频播放器组件,一个播放业务播放器状态发生变化,其他播放业务必须同步更新播放状态,各个播放业务之间互相交叉,随着播放业务的增多,开发和维护成本会急剧增加, 导致后续开发不可持续。 02.业务需求的目标 常见的业务需求 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 音视频播放框架 视频播放等于MediaPlayer和SurfaceView,MediaPlayer主要用于播放音频,没有提供图像输出界面,所以我们需要借助其他的组件来显示MediaPlayer播放的图像输出,我们可以使用SurfaceView来显示 能否实践开发出一套音视频播放的通用架构,能支持音频播放场景,也能播放视频场景,还可以无缝切换。比如视频切换音频操作,增强库的功能性 视频窗口、音频窗口、视频浮窗、音频浮窗、短视频窗口、短视频浮窗、音频控制台等多种场景播放,需要灵活切换,这个也是一个大的难点 03.该播放器框架特点 一定要解耦合 播放器内核与播放器解耦: 支持更多的播放场景、以及新的播放业务快速接入,并且不影响其他播放业务,比如后期添加阿里云播放器内核,或者腾讯播放器内核 播放器player与视频UI解耦:支持添加自定义视频视图,比如支持添加自定义广告,新手引导,或者视频播放异常等视图,这个需要较强的拓展性 适合多种业务场景 比如适合播放单个视频,多个视频,以及列表视频,或者类似抖音那种一个页面一个视频,还有小窗口播放视频。也就是适合大多数业务场景 播放器的整体层级图 播放器架构的介绍 基础内核播放库:提供基础的播放功能,可以自由切换内核,也方便拓展添加其他sdk内核播放器 统一播放器:屏蔽底层内核播放器播放差异,根据协议为上层提供统一的播放能力接口,供上层调用 播放视图层:负责播放器视图层的UI控制和调度,彻底解除播放业务与播放器的耦合 播放场景业务:负责向用户展示音视频播放能力和交互的业务 播放关联业务: 为播放器提供增值或支撑的业务,比如视频埋点统计,后期添加投屏,后期添加下载功能 demo:提供各种播放场景案例代码,基本上有大多数常用播放器的使用场景,建议直接看demo拿来即用 04.播放器内核封装 4.0 遇到的问题 播放器内核拓展难 不同的播放SDK提供的API都不一样,如果业务层对每个合作方都进行业务开发,就会导致业务量非常庞大,并且不同合作的方的播放SDK会产生交叉,不利于播放业务的维护和拓展。 播放器内核难以切换 不同的视频播放器内核,由于api不一样,所以难以切换操作。要是想兼容内核切换,就必须自己制定一个视频接口+实现类的播放器 4.1 视频播放器内核封装需求 一定要解耦合 播放器内核与播放器解耦: 支持更多的播放场景、以及新的播放业务快速接入,并且不影响其他播放业务,比如后期添加阿里云播放器内核,或者腾讯播放器内核 传入不同类型方便创建不同内核 隐藏内核播放器创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体播放器类的类名。需要符合开闭原则 具体设计方案 设计统一播放协议,对于上层播放业务,只调用按照统一协议设计接口,不必关心底层播放器的设计逻辑。保证上层播放业务不随新的接入播放SDK发生变化。 4.2 播放器内核架构图 播放器内核架构图 播放器内核代码说明 4.3 如何兼容不同内核播放器 提问:针对不同内核播放器,比如谷歌的ExoPlayer,B站的IjkPlayer,还有原生的MediaPlayer,有些api不一样,那使用的时候如何统一api呢? 比如说,ijk和exo的视频播放listener监听api就完全不同,这个时候需要做兼容处理 定义接口,然后各个不同内核播放器实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api 播放器内核 可以切换ExoPlayer、MediaPlayer,IjkPlayer,声网视频播放器,这里使用工厂模式Factory + AbstractVideoPlayer + 各个实现AbstractVideoPlayer抽象类的播放器类 定义抽象的播放器,主要包含视频初始化,设置,状态设置,以及播放监听。由于每个内核播放器api可能不一样,所以这里需要实现AbstractVideoPlayer抽象类的播放器类,方便后期统一调用 为了方便创建不同内核player,所以需要创建一个PlayerFactory,定义一个createPlayer创建播放器的抽象方法,然后各个内核都实现它,各自创建自己的播放器 关于AbstractVideoPlayer接口详细说明。这个接口定义通用视频播放器方法,比如常见的有:视频初始化,设置url,加载,以及播放状态,简单来说可以分为三个部分。 第一部分:视频初始化实例对象方法,主要包括:initPlayer初始化视频,setDataSource设置视频播放器地址,setSurface设置视频播放器渲染view,prepareAsync开始准备播放操作 第二部分:视频播放器状态方法,主要包括:播放,暂停,恢复,重制,设置进度,释放资源,获取进度,设置速度,设置音量 第三部分:player绑定view后,需要监听播放状态,比如播放异常,播放完成,播放准备,播放size变化,还有播放准备 播放器的核心实现要点 针对上层播放器业务,该内核库提供统一的播放暂停,设置播放状态的接口,由于播放器内核和播放器业务解耦合,所以非常方便快速添加其他sdk播放器,具体可以看这篇文章:05.视频播放器内核切换封装 05.播放器UI层封装 5.1 实际开发遇到问题 发展中遇到的问题 播放器可支持多种场景下的播放,多个产品会用到同一个播放器,这样就会带来一个问题,一个播放业务播放器状态发生变化,其他播放业务必须同步更新播放状态,各个播放业务之间互相交叉,随着播放业务的增多,开发和维护成本会急剧增加, 导致后续开发不可持续。 不太好适合多种业务场景 比如适合播放单个视频,多个视频,以及列表视频,或者类似抖音那种一个页面一个视频,还有小窗口播放视频。也就是适合大多数业务场景,视频通用性需要尽可能完善 5.2 如何分离播放和UI分离 VideoPlayer播放器 可以自由切换视频内核,Player+Controller。player负责播放的逻辑,Controller负责视图相关的逻辑,两者之间用接口进行通信 针对Controller,需要定义一个接口,主要负责视图UI处理逻辑,支持添加各种自定义视图View【统一实现自定义接口Control】,每个view尽量保证功能单一性,最后通过addView形式添加进来 针对Player,需要定义一个接口,主要负责视频播放处理逻辑,比如视频播放,暂停,设置播放进度,设置视频链接,切换播放模式等操作。需要注意把Controller设置到Player里面,两者之间通过接口交互 UI控制器视图 定义一个BaseVideoController类,这个主要是集成各种事件的处理逻辑,比如播放器状态改变,控制视图隐藏和显示,播放进度改变,锁定状态改变,设备方向监听等等操作 定义一个view的接口InterControlView,在这里类里定义绑定视图,视图隐藏和显示,播放状态,播放模式,播放进度,锁屏等操作。这个每个实现类则都可以拿到这些属性呢 在BaseVideoController中使用LinkedHashMap保存每个自定义view视图,添加则put进来后然后通过addView将视图添加到该控制器中,这样非常方便添加自定义视图 播放器切换状态需要改变Controller视图,比如视频异常则需要显示异常视图view,则它们之间的交互是通过ControlWrapper(同时实现Controller接口和Player接口)实现 具体如何实现呢 可以看这篇博客:06.播放器UI抽取封装 5.3 关于优先级视图展示 视频播放器为了拓展性,需要暴露view接口供外部开发者自定义视频播放器视图,通过addView的形式添加到播放器的控制器中。 这就涉及view视图的层级性。控制view视图的显示和隐藏是特别重要的,这个时候在自定义view中就需要拿到播放器的状态 举一个简单的例子,基础视频播放器 添加了基础播放功能的几个播放视图。有播放完成,播放异常,播放加载,顶部标题栏,底部控制条栏,锁屏,以及手势滑动栏。如何控制它们的显示隐藏切换呢? 在addView这些视图时,大多数的view都是默认GONE隐藏的。比如当视频初始化时,先缓冲则显示缓冲view而隐藏其他视图,接着播放则显示顶部/底部视图而隐藏其他视图 比如有时候需要显示两种不同的自定义视图如何处理 举个例子,播放的时候,点击一下视频,会显示顶部title视图和底部控制条视图,那么这样会同时显示两个视图。 点击顶部title视图的返回键可以关闭播放器,点击底部控制条视图的播放暂停可以控制播放条件。这个时候底部控制条视图FrameLayout的ChildView在整个视频的底部,顶部title视图FrameLayout的ChildView在整个视频的顶部,这样可以达到上下层都可以相应事件。 那么FrameLayout层层重叠,如何让下层不响应事件 在最上方显示的层加上: android:clickable="true" 可以避免点击上层触发底层。或者直接给控制设置一个background颜色也可以。 5.4 视频播放器重力感应监听 区别视频几种不同的播放模式 正常播放时,设置检查系统是否开启自动旋转,打开监听;全屏模式播放视频的时候,强制监听设备方向;在小窗口模式播放视频的时候,取消重力感应监听 注意一点。关于是否开启自动旋转的重力感应监听,可以给外部开发者暴露一个方法设置的开关。让用户选择是否开启该功能 具体怎么操作 写一个类,然后继承OrientationEventListener类,注意视频播放器重力感应监听不要那么频繁。表示500毫秒才检测一次…… mOrientationHelper.enable();表示检查系统是否开启自动旋转。mOrientationHelper.disable();表示取消监听 具体可以看这篇博客:06.播放器UI抽取封装 06.如何简单使用 6.1 播放单个视频 必须需要的四步骤代码如下所示 //创建基础视频播放器,一般播放器的功能 BasisVideoController controller = new BasisVideoController(this); //设置控制器 mVideoPlayer.setVideoController(controller); //设置视频播放链接地址 mVideoPlayer.setUrl(url); //开始播放 mVideoPlayer.start(); 只需要四步操作即可,非常简单。这样就可以满足一个基础的视频播放器 具体逻辑可以看:BasisVideoController 如何添加只定义视图,非常方便。AdControlView需要实现InterControlView接口才可以 AdControlView adControlView = new AdControlView(this); controller.addControlComponent(adControlView); 要是一个页面播放多个视频怎么办 直接创建两个VideoPlayer,实现代码和播放单个视频一样,只是需要注意:不要开启音频焦点监听。 如果是开启的音频焦点改变监听,那么播放该视频的时候,就会停止其他音视频的播放操作。类似,你听音乐,这个时候去看视频,那么音乐就暂停呢 6.2 列表播放视频 关于列表播放视频,该案例支持 列表页面有多个item 第一种:点击item播放,当item滑动到不可见时暂停播放;点击其他可见item播放视频,则会暂停其他正在播放的视频,也就是说一次只能播放一个视频 第二种:滑动item,用户不用点击,让其自动进行播放,这种业务场景在玩手机碰到过。大概思路时,进入列表自动播放第一个,然后在RecyclerView滑动监听的方法中,判断如果页面滑动停止了,则遍历RecyclerView子控件找到第一个完全可见的item,然后拿到该item的索引即可播放该位置的视频 列表页面是一个页面一个item 第一种操作使用ViewPager,是垂直方向可以滚动的VerticalViewPager + PagerAdapter,这种方式在item创建上可以设置预加载加载布局视图 第二种操作使用RecyclerView,是用ScrollPageHelper + RecyclerView,这种方式也可以实现一个页面一个item,一次滑动一个 如何保证在列表中只播放一个视频。两种方案 第一种:每个item放一个VideoPlayer,但是要注意需要用一个单例VideoPlayerManager来保证只有一个VideoPlayer对象,这样就可以保证一次播放一个视频。当ViewHolder中的视图被回收时需要销毁视频资源 第二种:只创建一个VideoPlayer,那个播放就添加到具体的item布局中。比如播放第一个视频就把player对象添加到视图中,点击播放第三个时需要把player从它的父布局中移除后然后再添加到该item的布局中,这样就可以实现 list条目中滑动item不可见就停止视频播放 在列表中播放,可以监听RecyclerView中的item生命周期,有一个AttachedToWindow是绑定item视图,还有一个DetachedFromWindow方法是item离开窗口时调用,在这个里面可以做视频销毁的逻辑。 07.如何自定义播放器 BasisVideoController已经满足基础视频播放器功能 在该控制器中,已经做了相关的初始化操作,比如设置视频可以拖动,根据屏幕方向自动进入/退出全屏,设置滑动调节亮度,音量,进度,默认开启等操作。 快速添加基础视频播放器的模块,包括视频播放完成view,播放异常view,播放top视图view,播放底部控制蓝view,手势滑动视图view等。同时在每一个视图view中可以拿到视频播放器的状态,便于设置UI的操作。 比如在此播放器基础上,添加广告视图view 现在有个业务需求,需要在视频播放器刚开始添加一个广告视图,等待广告倒计时120秒后,直接进入播放视频逻辑。相信这个业务场景很常见,大家都碰到过,使用该播放器就特别简单。 首先创建一个自定义view,需要实现InterControlView接口,重写该接口中所有抽象方法,这里省略了很多代码,具体看demo。最后在调用controller.addControlComponent(adControlView);添加到基础视频播放器,这种方式满足大多数的场景…… 那要是基础播放器不满足UI该怎么办? 好办,直接仿照BasisVideoController创建一个你自己的控制器,ui想怎么定制你自己决定。比如说你要实现一个小窗口播放视频,那这个时候肯定需要定制,照葫芦画瓢,具体可以看CustomFloatController类。 08.该案例的拓展性分享 可以配置多个内核切换 只需要你在配置的时候,传入不同的类型即可创建不同的播放器内核,十分方便。如果后期你要拓展其他的内核播放器,只需要按照exo的代码案例弄一套即可,十分方便,加入其他内核播放器不会影响到你的业务。 PlayerFactory player = PlayerFactoryUtils.getPlayer(PlayerConstant.PlayerType.TYPE_IJK); 可以配置统一视频埋点监听 避免在每个带有视频的页面activity或者Fragment中添加埋点,而是有播放器框架内部提供一个埋点的接口,外部开发者只需要实现这个接口即可全局埋点视频播放器,非常方便和管理维护,针对接口增加或者删除都是不影响你其他的业务。 开发者可以自由添加自定义视频视图 在封装BaseVideoController控制器的时候,考虑到后期的拓展性,把视频各个视频都是以addView的形式添加进来,使用LinkedHashMap存储这样可以保证顺序。 需要注意的是在这个Controller中,需要把播放器的播放状态,播放模式,播放进度,锁屏等操作给绑定到开发者自定义实现的播放器视图View中。 暴露众多视频操作的方法给开发者 比如给视频设置封面图片,这个时候总不能在播放器内部引入一个Glide,然后加载图片,这样和业务耦合呢。可以把这个设置封面view暴露给开发者,然后设置,这样更好一些。 比如外部开发者想要知道视频播放器的状态,做一些业务上操作,这个时候完全可以通过接口的形式暴露出来,该播放器把视频的播放模式监听,播放状态监听,还有各种视频操作都暴露了方法出来,方便开发者调用。 09.关于视频缓存方案 网络上比较好的项目:https://github.com/danikula/AndroidVideoCache 网络用的HttpURLConnection,文件缓存处理,文件最大限度策略,回调监听处理,断点续传,代理服务等。 但是存在一些问题,比如如下所示 文件的缓存超过限制后没有按照lru算法删除, 处理返回给播放器的http响应头消息,响应头消息的获取处理改为head请求(需服务器支持) 替换网络库为okHttp(因为大部分的项目都是以okHttp为网络请求库的),但是这个改动性比较大 然后看一下怎么使用,超级简单。传入视频url链接,返回一个代理链接,然后就可以呢 HttpProxyCacheServer server = new HttpProxyCacheServer(this); String proxyVideoUrl = server.getProxyUrl(URL_AD); 大概的原理 原始的方式是直接塞播放地址给播放器,它就可以直接播放。现在我们要在中间加一层本地代理,播放器播放的时候(获取数据)是通过我们的本地代理的地址来播放的,这样我们就可以很好的在中间层(本地代理层)做一些处理,比如:文件缓存,预缓存(秒开处理),监控等。 原理详细一点来说 1.采用了本地代理服务的方式,通过原始url给播放器返回一个本地代理的一个url ,代理URL类似:http://127.0.0.1:port/视频url;(port端口为系统随机分配的有效端口,真实url是为了真正的下载),然后播放器播放的时候请求到了你本地的代理上了。 2.本地代理采用ServerSocket监听127.0.0.1的有效端口,这个时候手机就是一个服务器了,客户端就是socket,也就是播放器。 3.读取客户端就是socket来读取数据(http协议请求)解析http协议。 4.根据url检查视频文件是否存在,读取文件数据给播放器,也就是往socket里写入数据(socket通信)。同时如果没有下载完成会进行断点下载,当然弱网的话数据需要生产消费同步处理。 如何实现预加载 其实预加载的思路很简单,在进行一个播放视频后,再返回接下来需要预加载的视频url,启用线程去请求下载数据 开启一个线程去请求并预加载一部分的数据,可能需要预加载的数据大于>1,利用队列先进入的先进行加载,因此可以采用LinkedHashMap保存正在预加载的task。 在开始预加载的时候,判断该播放地址是否已经预加载,如果不是那么创建一个线程task,并且把它放到map集合中。然后执行预加载逻辑,也就是执行HttpURLConnection请求 提供取消对应url加载的任务,因为有可能该url不需要再进行预加载了,比如参考抖音,当用户瞬间下滑几个视频,那么很多视频就需要跳过了不需要再进行预加载。这个后期在做 10.如何监控视频埋点 传统一点的做法 比如用友盟或者百度统计,或者用其他的统计。之前的做法是,在每个有视频的页面比如说Activity,Fragment等开启时视频播放时埋点一次,页面退出时埋点一次。 如果app中有多个activity或者fragment页面,那么就每个页面都要进行埋点。比如如果你的app是付费视频,你想知道有多少人试看了,该怎么操作。那么你需要在每一个有视频的activity页面挨个添加埋点,那还有没有更好的办法? 解决方案 举个例子:例如,你需要来让外部开发者手动去埋点,可是在类中怎么埋点又是由其他人来设计的,你只是需要对外暴露监听的方法。那么该如何做呢?采用接口 + 实现类方式即可实现。 该案例中怎么操作 定义一个接口,规定其他人设计类,必须继承这个接口。在这个接口中,定义进入视频播放,退出视频播放器,记录播放进度,视频播放完成,播放异常,点击广告,点击试看等操作的抽象方法。具体可以看BuriedPointEvent类代码…… 外部开发者如何使用 定义一个类实现该视频埋点接口,重写里面方法。然后需要在初始化配置视频播放器的时候,将这个实现类的对象传递进来即可。通过这个配置类传进来的对象,播放器就可以处理监听设置逻辑呢。 这种操作最大的好处就是:在这个类中统一处理视频的埋点,修改快捷,而不用在每一个有视频播放器的页面埋点,方便维护。比如如何处理视频播放完成监听,代码如下所示: @Override public void onCompletion() { VideoPlayerConfig config = VideoViewManager.getConfig(); if (config!=null && config.mBuriedPointEvent!=null){ //视频播放完成 config.mBuriedPointEvent.playerCompletion(mUrl); } } 11.待实现的需求分析 音视频无缝切换 比如在豆神教育中,有视频播放,也有音频播放,这两块都是写到了业务代码中,能否将两者糅合起来。但音频相比视频,多了一个可以在后台播放的功能,一般用在service中,这一相互切换需求待完善。以满足后期可能出现的需求功能。 优化播放器持续平滑播放 画中画方案:虽然Android8.0及其以上版本已提供了画中画方案,但是Android8.0以下版本仍然保有大量用户,其缺点就是无法满足Android8.0以下用户需; 采用系统浮层:采用系统浮层需要系统浮层权限,Android厂商对系统浮层的授权越来越严格,导致用户授权过程的体验比较差;需要权限,可能有些手机不太好适配; 在每个展示页面单独添加播放器浮窗:优点是不受Android系统版本限制,并且用户无需系统浮层权限授权,适合所有手机用户,体验较好 12.一些细节上优化 多使用注解限定符 对于一些关于类型的方法参数,可以多用注解限定符,暴露给外部开发者调用的方法,可以防止传入正确的类型。比如:PlayerFactoryUtils.getPlayer(PlayerConstant.PlayerType.TYPE_IJK) 完善的api文档 api文档充分完善到每一个细节,以及配套demo,方便快速上手。完善的代码注释,以及项目的类结构图,方便快速了解视频播放器的整体轮廓 丰富的demo案例 提供绝大多数场景的视频播放器功能,完全可以套用demo中的案例,甚至你还可以在案例基础上大幅度优化 13.参考案例和博客记录 exo播放器 https://github.com/google/ExoPlayer ijk播放器 https://github.com/bilibili/ijkplayer 阿里云播放器 https://help.aliyun.com/document_detail/51992.html?spm=a2c4g.11186623.2.24.37131bc7j1PoVK#topic2415 GSY播放器 https://github.com/CarGuo/GSYVideoPlayer 饺子播放器 https://github.com/lipangit/JiaoZiVideoPlayer 项目地址:https://github.com/yangchong211/YCVideoPlayer
05.视频播放器内核切换封装 目录介绍 01.视频播放器内核封装需求 02.播放器内核架构图 03.如何兼容不同内核播放器 04.看一下ijk的内核实现类 05.看一下exo的内核实现类 06.如何创建不同内核播放器 07.看一下工厂类实现代码 08.后期如何添加新的内核 00.视频播放器通用框架 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 该播放器整体架构:播放器内核(自由切换) + 视频播放器 + 边播边缓存 + 高度定制播放器UI视图层 项目地址:https://github.com/yangchong211/YCVideoPlayer 关于视频播放器整体功能介绍文档:https://juejin.im/post/6883457444752654343 01.视频播放器内核封装需求 播放器内核难以切换 不同的视频播放器内核,由于api不一样,所以难以切换操作。要是想兼容内核切换,就必须自己制定一个视频接口+实现类的播放器 一定要解耦合 播放器内核与播放器解耦: 支持更多的播放场景、以及新的播放业务快速接入,并且不影响其他播放业务,比如后期添加阿里云播放器内核,或者腾讯播放器内核 传入不同类型方便创建不同内核 隐藏内核播放器创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体播放器类的类名。需要符合开闭原则 02.播放器内核架构图 03.如何兼容不同内核播放器 提问:针对不同内核播放器,比如谷歌的ExoPlayer,B站的IjkPlayer,还有原生的MediaPlayer,有些api不一样,那使用的时候如何统一api呢? 比如说,ijk和exo的视频播放listener监听api就完全不同,这个时候需要做兼容处理 定义接口,然后各个不同内核播放器实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api 定义一个接口,这个接口有什么呢?这个接口定义通用视频播放器方法,比如常见的有:视频初始化,设置url,加载,以及播放状态,简单来说可以分为三个部分。 第一部分:视频初始化实例对象方法,主要包括:initPlayer初始化视频,setDataSource设置视频播放器地址,setSurface设置视频播放器渲染view,prepareAsync开始准备播放操作 第二部分:视频播放器状态方法,主要包括:播放,暂停,恢复,重制,设置进度,释放资源,获取进度,设置速度,设置音量 第三部分:player绑定view后,需要监听播放状态,比如播放异常,播放完成,播放准备,播放size变化,还有播放准备 04.看一下ijk的内核实现类 ijk的内核实现类代码如下所示 public class IjkVideoPlayer extends AbstractVideoPlayer { protected IjkMediaPlayer mMediaPlayer; private int mBufferedPercent; private Context mAppContext; public IjkVideoPlayer(Context context) { if (context instanceof Application){ mAppContext = context; } else { mAppContext = context.getApplicationContext(); } } @Override public void initPlayer() { mMediaPlayer = new IjkMediaPlayer(); //native日志 IjkMediaPlayer.native_setLogLevel(VideoLogUtils.isIsLog() ? IjkMediaPlayer.IJK_LOG_INFO : IjkMediaPlayer.IJK_LOG_SILENT); setOptions(); mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); initListener(); } @Override public void setOptions() { } /** * ijk视频播放器监听listener */ private void initListener() { // 设置监听,可以查看ijk中的IMediaPlayer源码监听事件 // 设置视频错误监听器 mMediaPlayer.setOnErrorListener(onErrorListener); // 设置视频播放完成监听事件 mMediaPlayer.setOnCompletionListener(onCompletionListener); // 设置视频信息监听器 mMediaPlayer.setOnInfoListener(onInfoListener); // 设置视频缓冲更新监听事件 mMediaPlayer.setOnBufferingUpdateListener(onBufferingUpdateListener); // 设置准备视频播放监听事件 mMediaPlayer.setOnPreparedListener(onPreparedListener); // 设置视频大小更改监听器 mMediaPlayer.setOnVideoSizeChangedListener(onVideoSizeChangedListener); // 设置视频seek完成监听事件 mMediaPlayer.setOnSeekCompleteListener(onSeekCompleteListener); // 设置时间文本监听器 mMediaPlayer.setOnTimedTextListener(onTimedTextListener); mMediaPlayer.setOnNativeInvokeListener(new IjkMediaPlayer.OnNativeInvokeListener() { @Override public boolean onNativeInvoke(int i, Bundle bundle) { return true; } }); } /** * 设置播放地址 * * @param path 播放地址 * @param headers 播放地址请求头 */ @Override public void setDataSource(String path, Map<String, String> headers) { // 设置dataSource if(path==null || path.length()==0){ if (mPlayerEventListener!=null){ mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_URL_NULL, 0); } return; } try { //解析path Uri uri = Uri.parse(path); if (ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme())) { RawDataSourceProvider rawDataSourceProvider = RawDataSourceProvider.create(mAppContext, uri); mMediaPlayer.setDataSource(rawDataSourceProvider); } else { //处理UA问题 if (headers != null) { String userAgent = headers.get("User-Agent"); if (!TextUtils.isEmpty(userAgent)) { mMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "user_agent", userAgent); } } mMediaPlayer.setDataSource(mAppContext, uri, headers); } } catch (Exception e) { mPlayerEventListener.onError(); } } /** * 用于播放raw和asset里面的视频文件 */ @Override public void setDataSource(AssetFileDescriptor fd) { try { mMediaPlayer.setDataSource(new RawDataSourceProvider(fd)); } catch (Exception e) { mPlayerEventListener.onError(); } } /** * 设置渲染视频的View,主要用于TextureView * @param surface surface */ @Override public void setSurface(Surface surface) { mMediaPlayer.setSurface(surface); } /** * 准备开始播放(异步) */ @Override public void prepareAsync() { try { mMediaPlayer.prepareAsync(); } catch (IllegalStateException e) { mPlayerEventListener.onError(); } } /** * 暂停 */ @Override public void pause() { try { mMediaPlayer.pause(); } catch (IllegalStateException e) { mPlayerEventListener.onError(); } } /** * 播放 */ @Override public void start() { try { mMediaPlayer.start(); } catch (IllegalStateException e) { mPlayerEventListener.onError(); } } /** * 停止 */ @Override public void stop() { try { mMediaPlayer.stop(); } catch (IllegalStateException e) { mPlayerEventListener.onError(); } } /** * 重置播放器 */ @Override public void reset() { mMediaPlayer.reset(); mMediaPlayer.setOnVideoSizeChangedListener(onVideoSizeChangedListener); setOptions(); } /** * 是否正在播放 */ @Override public boolean isPlaying() { return mMediaPlayer.isPlaying(); } /** * 调整进度 */ @Override public void seekTo(long time) { try { mMediaPlayer.seekTo((int) time); } catch (IllegalStateException e) { mPlayerEventListener.onError(); } } /** * 释放播放器 */ @Override public void release() { mMediaPlayer.setOnErrorListener(null); mMediaPlayer.setOnCompletionListener(null); mMediaPlayer.setOnInfoListener(null); mMediaPlayer.setOnBufferingUpdateListener(null); mMediaPlayer.setOnPreparedListener(null); mMediaPlayer.setOnVideoSizeChangedListener(null); new Thread() { @Override public void run() { try { mMediaPlayer.release(); } catch (Exception e) { e.printStackTrace(); } } }.start(); } /** * 获取当前播放的位置 */ @Override public long getCurrentPosition() { return mMediaPlayer.getCurrentPosition(); } /** * 获取视频总时长 */ @Override public long getDuration() { return mMediaPlayer.getDuration(); } /** * 获取缓冲百分比 */ @Override public int getBufferedPercentage() { return mBufferedPercent; } /** * 设置渲染视频的View,主要用于SurfaceView */ @Override public void setDisplay(SurfaceHolder holder) { mMediaPlayer.setDisplay(holder); } /** * 设置音量 */ @Override public void setVolume(float v1, float v2) { mMediaPlayer.setVolume(v1, v2); } /** * 设置是否循环播放 */ @Override public void setLooping(boolean isLooping) { mMediaPlayer.setLooping(isLooping); } /** * 设置播放速度 */ @Override public void setSpeed(float speed) { mMediaPlayer.setSpeed(speed); } /** * 获取播放速度 */ @Override public float getSpeed() { return mMediaPlayer.getSpeed(0); } /** * 获取当前缓冲的网速 */ @Override public long getTcpSpeed() { return mMediaPlayer.getTcpSpeed(); } /** * 设置视频错误监听器 * int MEDIA_INFO_VIDEO_RENDERING_START = 3;//视频准备渲染 * int MEDIA_INFO_BUFFERING_START = 701;//开始缓冲 * int MEDIA_INFO_BUFFERING_END = 702;//缓冲结束 * int MEDIA_INFO_VIDEO_ROTATION_CHANGED = 10001;//视频选择信息 * int MEDIA_ERROR_SERVER_DIED = 100;//视频中断,一般是视频源异常或者不支持的视频类型。 * int MEDIA_ERROR_IJK_PLAYER = -10000,//一般是视频源有问题或者数据格式不支持,比如音频不是AAC之类的 * int MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK = 200;//数据错误没有有效的回收 */ private IMediaPlayer.OnErrorListener onErrorListener = new IMediaPlayer.OnErrorListener() { @Override public boolean onError(IMediaPlayer iMediaPlayer, int framework_err, int impl_err) { mPlayerEventListener.onError(); VideoLogUtils.d("IjkVideoPlayer----listener---------onError ——> STATE_ERROR ———— what:" + framework_err + ", extra: " + impl_err); return true; } }; /** * 设置视频播放完成监听事件 */ private IMediaPlayer.OnCompletionListener onCompletionListener = new IMediaPlayer.OnCompletionListener() { @Override public void onCompletion(IMediaPlayer iMediaPlayer) { mPlayerEventListener.onCompletion(); VideoLogUtils.d("IjkVideoPlayer----listener---------onCompletion ——> STATE_COMPLETED"); } }; /** * 设置视频信息监听器 */ private IMediaPlayer.OnInfoListener onInfoListener = new IMediaPlayer.OnInfoListener() { @Override public boolean onInfo(IMediaPlayer iMediaPlayer, int what, int extra) { mPlayerEventListener.onInfo(what, extra); VideoLogUtils.d("IjkVideoPlayer----listener---------onInfo ——> ———— what:" + what + ", extra: " + extra); return true; } }; /** * 设置视频缓冲更新监听事件 */ private IMediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener = new IMediaPlayer.OnBufferingUpdateListener() { @Override public void onBufferingUpdate(IMediaPlayer iMediaPlayer, int percent) { mBufferedPercent = percent; } }; /** * 设置准备视频播放监听事件 */ private IMediaPlayer.OnPreparedListener onPreparedListener = new IMediaPlayer.OnPreparedListener() { @Override public void onPrepared(IMediaPlayer iMediaPlayer) { mPlayerEventListener.onPrepared(); VideoLogUtils.d("IjkVideoPlayer----listener---------onPrepared ——> STATE_PREPARED"); } }; /** * 设置视频大小更改监听器 */ private IMediaPlayer.OnVideoSizeChangedListener onVideoSizeChangedListener = new IMediaPlayer.OnVideoSizeChangedListener() { @Override public void onVideoSizeChanged(IMediaPlayer iMediaPlayer, int width, int height, int sar_num, int sar_den) { int videoWidth = iMediaPlayer.getVideoWidth(); int videoHeight = iMediaPlayer.getVideoHeight(); if (videoWidth != 0 && videoHeight != 0) { mPlayerEventListener.onVideoSizeChanged(videoWidth, videoHeight); } VideoLogUtils.d("IjkVideoPlayer----listener---------onVideoSizeChanged ——> WIDTH:" + width + ", HEIGHT:" + height); } }; /** * 设置时间文本监听器 */ private IMediaPlayer.OnTimedTextListener onTimedTextListener = new IMediaPlayer.OnTimedTextListener() { @Override public void onTimedText(IMediaPlayer iMediaPlayer, IjkTimedText ijkTimedText) { } }; /** * 设置视频seek完成监听事件 */ private IMediaPlayer.OnSeekCompleteListener onSeekCompleteListener = new IMediaPlayer.OnSeekCompleteListener() { @Override public void onSeekComplete(IMediaPlayer iMediaPlayer) { } }; } 05.看一下exo的内核实现类 exo的内核实现类代码如下所示,和ijk的api有些区别 public class ExoMediaPlayer extends AbstractVideoPlayer implements VideoListener, Player.EventListener { protected Context mAppContext; protected SimpleExoPlayer mInternalPlayer; protected MediaSource mMediaSource; protected ExoMediaSourceHelper mMediaSourceHelper; private PlaybackParameters mSpeedPlaybackParameters; private int mLastReportedPlaybackState = Player.STATE_IDLE; private boolean mLastReportedPlayWhenReady = false; private boolean mIsPreparing; private boolean mIsBuffering; private LoadControl mLoadControl; private RenderersFactory mRenderersFactory; private TrackSelector mTrackSelector; public ExoMediaPlayer(Context context) { if (context instanceof Application){ mAppContext = context; } else { mAppContext = context.getApplicationContext(); } mMediaSourceHelper = ExoMediaSourceHelper.getInstance(context); } @Override public void initPlayer() { //创建exo播放器 mInternalPlayer = new SimpleExoPlayer.Builder( mAppContext, mRenderersFactory == null ? mRenderersFactory = new DefaultRenderersFactory(mAppContext) : mRenderersFactory, mTrackSelector == null ? mTrackSelector = new DefaultTrackSelector(mAppContext) : mTrackSelector, mLoadControl == null ? mLoadControl = new DefaultLoadControl() : mLoadControl, DefaultBandwidthMeter.getSingletonInstance(mAppContext), Util.getLooper(), new AnalyticsCollector(Clock.DEFAULT), /* useLazyPreparation= */ true, Clock.DEFAULT) .build(); setOptions(); //播放器日志 if (VideoLogUtils.isIsLog() && mTrackSelector instanceof MappingTrackSelector) { mInternalPlayer.addAnalyticsListener(new EventLogger((MappingTrackSelector) mTrackSelector, "ExoPlayer")); } initListener(); } /** * exo视频播放器监听listener */ private void initListener() { mInternalPlayer.addListener(this); mInternalPlayer.addVideoListener(this); } public void setTrackSelector(TrackSelector trackSelector) { mTrackSelector = trackSelector; } public void setRenderersFactory(RenderersFactory renderersFactory) { mRenderersFactory = renderersFactory; } public void setLoadControl(LoadControl loadControl) { mLoadControl = loadControl; } /** * 设置播放地址 * * @param path 播放地址 * @param headers 播放地址请求头 */ @Override public void setDataSource(String path, Map<String, String> headers) { // 设置dataSource if(path==null || path.length()==0){ if (mPlayerEventListener!=null){ mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_URL_NULL, 0); } return; } mMediaSource = mMediaSourceHelper.getMediaSource(path, headers); } @Override public void setDataSource(AssetFileDescriptor fd) { //no support } /** * 准备开始播放(异步) */ @Override public void prepareAsync() { if (mInternalPlayer == null){ return; } if (mMediaSource == null){ return; } if (mSpeedPlaybackParameters != null) { mInternalPlayer.setPlaybackParameters(mSpeedPlaybackParameters); } mIsPreparing = true; mMediaSource.addEventListener(new Handler(), mMediaSourceEventListener); //准备播放 mInternalPlayer.prepare(mMediaSource); } /** * 播放 */ @Override public void start() { if (mInternalPlayer == null){ return; } mInternalPlayer.setPlayWhenReady(true); } /** * 暂停 */ @Override public void pause() { if (mInternalPlayer == null){ return; } mInternalPlayer.setPlayWhenReady(false); } /** * 停止 */ @Override public void stop() { if (mInternalPlayer == null){ return; } mInternalPlayer.stop(); } private MediaSourceEventListener mMediaSourceEventListener = new MediaSourceEventListener() { @Override public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { if (mPlayerEventListener != null && mIsPreparing) { mPlayerEventListener.onPrepared(); } } }; /** * 重置播放器 */ @Override public void reset() { if (mInternalPlayer != null) { mInternalPlayer.stop(true); mInternalPlayer.setVideoSurface(null); mIsPreparing = false; mIsBuffering = false; mLastReportedPlaybackState = Player.STATE_IDLE; mLastReportedPlayWhenReady = false; } } /** * 是否正在播放 */ @Override public boolean isPlaying() { if (mInternalPlayer == null){ return false; } int state = mInternalPlayer.getPlaybackState(); switch (state) { case Player.STATE_BUFFERING: case Player.STATE_READY: return mInternalPlayer.getPlayWhenReady(); case Player.STATE_IDLE: case Player.STATE_ENDED: default: return false; } } /** * 调整进度 */ @Override public void seekTo(long time) { if (mInternalPlayer == null){ return; } mInternalPlayer.seekTo(time); } /** * 释放播放器 */ @Override public void release() { if (mInternalPlayer != null) { mInternalPlayer.removeListener(this); mInternalPlayer.removeVideoListener(this); final SimpleExoPlayer player = mInternalPlayer; mInternalPlayer = null; new Thread() { @Override public void run() { //异步释放,防止卡顿 player.release(); } }.start(); } mIsPreparing = false; mIsBuffering = false; mLastReportedPlaybackState = Player.STATE_IDLE; mLastReportedPlayWhenReady = false; mSpeedPlaybackParameters = null; } /** * 获取当前播放的位置 */ @Override public long getCurrentPosition() { if (mInternalPlayer == null){ return 0; } return mInternalPlayer.getCurrentPosition(); } /** * 获取视频总时长 */ @Override public long getDuration() { if (mInternalPlayer == null){ return 0; } return mInternalPlayer.getDuration(); } /** * 获取缓冲百分比 */ @Override public int getBufferedPercentage() { return mInternalPlayer == null ? 0 : mInternalPlayer.getBufferedPercentage(); } /** * 设置渲染视频的View,主要用于SurfaceView */ @Override public void setSurface(Surface surface) { if (mInternalPlayer != null) { mInternalPlayer.setVideoSurface(surface); } } @Override public void setDisplay(SurfaceHolder holder) { if (holder == null){ setSurface(null); } else{ setSurface(holder.getSurface()); } } /** * 设置音量 */ @Override public void setVolume(float leftVolume, float rightVolume) { if (mInternalPlayer != null){ mInternalPlayer.setVolume((leftVolume + rightVolume) / 2); } } /** * 设置是否循环播放 */ @Override public void setLooping(boolean isLooping) { if (mInternalPlayer != null){ mInternalPlayer.setRepeatMode(isLooping ? Player.REPEAT_MODE_ALL : Player.REPEAT_MODE_OFF); } } @Override public void setOptions() { //准备好就开始播放 mInternalPlayer.setPlayWhenReady(true); } /** * 设置播放速度 */ @Override public void setSpeed(float speed) { PlaybackParameters playbackParameters = new PlaybackParameters(speed); mSpeedPlaybackParameters = playbackParameters; if (mInternalPlayer != null) { mInternalPlayer.setPlaybackParameters(playbackParameters); } } /** * 获取播放速度 */ @Override public float getSpeed() { if (mSpeedPlaybackParameters != null) { return mSpeedPlaybackParameters.speed; } return 1f; } /** * 获取当前缓冲的网速 */ @Override public long getTcpSpeed() { // no support return 0; } @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { if (mPlayerEventListener == null){ return; } if (mIsPreparing){ return; } if (mLastReportedPlayWhenReady != playWhenReady || mLastReportedPlaybackState != playbackState) { switch (playbackState) { //最开始调用的状态 case Player.STATE_IDLE: break; //开始缓充 case Player.STATE_BUFFERING: mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_BUFFERING_START, getBufferedPercentage()); mIsBuffering = true; break; //开始播放 case Player.STATE_READY: if (mIsBuffering) { mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_BUFFERING_END, getBufferedPercentage()); mIsBuffering = false; } break; //播放器已经播放完了媒体 case Player.STATE_ENDED: mPlayerEventListener.onCompletion(); break; default: break; } mLastReportedPlaybackState = playbackState; mLastReportedPlayWhenReady = playWhenReady; } } @Override public void onPlayerError(ExoPlaybackException error) { if (mPlayerEventListener != null) { mPlayerEventListener.onError(); } } @Override public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { if (mPlayerEventListener != null) { mPlayerEventListener.onVideoSizeChanged(width, height); if (unappliedRotationDegrees > 0) { mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_VIDEO_ROTATION_CHANGED, unappliedRotationDegrees); } } } @Override public void onRenderedFirstFrame() { if (mPlayerEventListener != null && mIsPreparing) { mPlayerEventListener.onInfo(PlayerConstant.MEDIA_INFO_VIDEO_RENDERING_START, 0); mIsPreparing = false; } } } 06.如何创建不同内核播放器 先来看一下创建不同内核播放器的代码,只需要开发者传入一个类型参数,即可创建不同类的实例对象。代码如下所示 /** * 获取PlayerFactory具体实现类,获取内核 * 创建对象的时候只需要传递类型type,而不需要对应的工厂,即可创建具体的产品对象 * TYPE_IJK IjkPlayer,基于IjkPlayer封装播放器 * TYPE_NATIVE MediaPlayer,基于原生自带的播放器控件 * TYPE_EXO 基于谷歌视频播放器 * TYPE_RTC 基于RTC视频播放器 * @param type 类型 * @return */ public static AbstractVideoPlayer getVideoPlayer(Context context,@PlayerConstant.PlayerType int type){ if (type == PlayerConstant.PlayerType.TYPE_EXO){ return ExoPlayerFactory.create().createPlayer(context); } else if (type == PlayerConstant.PlayerType.TYPE_IJK){ return IjkPlayerFactory.create().createPlayer(context); } else if (type == PlayerConstant.PlayerType.TYPE_NATIVE){ return MediaPlayerFactory.create().createPlayer(context); } else if (type == PlayerConstant.PlayerType.TYPE_RTC){ return IjkPlayerFactory.create().createPlayer(context); } else { return IjkPlayerFactory.create().createPlayer(context); } } 使用工厂模式创建不同对象的动机是什么,为何要这样使用? 一个视频播放器可以提供多个内核Player(如ijk、exo、media,rtc等等), 这些player都源自同一个基类,不过在继承基类后不同的子类修改了部分属性从而使得它们可以呈现不同的外观。 如果希望在使用这些内核player时,不需要知道这些具体内核的名字,只需要知道表示该内核类的一个参数,并提供一个调用方便的方法,把该参数传入方法即可返回一个相应的内核对象,此时,就可以使用工厂模式。 首先定义一个工厂抽象类,然后不同的内核播放器分别创建其具体的工厂实现具体类 PlayerFactory:抽象工厂,担任这个角色的是工厂方法模式的核心,任何在模式中创建对象的工厂类必须实现这个接口 ExoPlayerFactory:具体工厂,具体工厂角色含有与业务密切相关的逻辑,并且受到使用者的调用以创建具体产品对象。 如何使用,分为三步,具体操作如下所示 1.先调用具体工厂对象中的方法createPlayer方法;2.根据传入产品类型参数获得具体的产品对象;3.返回产品对象并使用。 简而言之,创建对象的时候只需要传递类型type,而不需要对应的工厂,即可创建具体的产品对象 07.看一下工厂类实现代码 抽象工厂类,代码如下所示 public abstract class PlayerFactory<T extends AbstractVideoPlayer> { public abstract T createPlayer(Context context); } 具体实现工厂类,代码如下所示 public class ExoPlayerFactory extends PlayerFactory<ExoMediaPlayer> { public static ExoPlayerFactory create() { return new ExoPlayerFactory(); } @Override public ExoMediaPlayer createPlayer(Context context) { return new ExoMediaPlayer(context); } } 这种创建对象最大优点 工厂方法用来创建所需要的产品,同时隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体产品类的类名。 加入新的产品时,比如后期新加一个阿里播放器内核,这个时候就只需要添加一个具体工厂和具体产品就可以。系统的可扩展性也就变得非常好,完全符合“开闭原则” 08.后期如何添加新的内核 比如后期想要添加一个腾讯视频内核的播放器。代码如下所示,这个是简化的 public class TxPlayerFactory extends PlayerFactory<TxMediaPlayer> { public static TxPlayerFactory create() { return new TxPlayerFactory(); } @Override public TxMediaPlayer createPlayer(Context context) { return new TxMediaPlayer(context); } } public class TxMediaPlayer extends AbstractVideoPlayer { //省略接口的实现方法代码 }
03.视频播放器Api说明 目录介绍 01.最简单的播放 02.如何切换视频内核 03.切换视频模式 04.切换视频清晰度 05.视频播放监听 06.列表中播放处理 07.悬浮窗口播放 08.其他重要功能Api 09.播放多个视频 10.VideoPlayer相关Api 11.Controller相关Api 12.边播放边缓存api 13.类似抖音视频预加载 14.视频播放器埋点 00.视频播放器通用框架 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 该播放器整体架构:播放器内核(自由切换) + 视频播放器 + 边播边缓存 + 高度定制播放器UI视图层 项目地址:https://github.com/yangchong211/YCVideoPlayer 关于视频播放器整体功能介绍文档:https://juejin.im/post/6883457444752654343 01.最简单的播放 必须需要的四步骤代码如下所示 //创建基础视频播放器,一般播放器的功能 BasisVideoController controller = new BasisVideoController(this); //设置控制器 mVideoPlayer.setVideoController(controller); //设置视频播放链接地址 mVideoPlayer.setUrl(url); //开始播放 mVideoPlayer.start(); 开始播放 //播放视频 videoPlayer.start(); 02.如何切换视频内核 创建视频播放器 PlayerFactory playerFactory = IjkPlayerFactory.create(); IjkVideoPlayer ijkVideoPlayer = (IjkVideoPlayer) playerFactory.createPlayer(this); PlayerFactory playerFactory = ExoPlayerFactory.create(); ExoMediaPlayer exoMediaPlayer = (ExoMediaPlayer) playerFactory.createPlayer(this); PlayerFactory playerFactory = MediaPlayerFactory.create(); AndroidMediaPlayer androidMediaPlayer = (AndroidMediaPlayer) playerFactory.createPlayer(this); 如何配置视频内核 //播放器配置,注意:此为全局配置,例如下面就是配置ijk内核播放器 VideoViewManager.setConfig(VideoPlayerConfig.newBuilder() .setLogEnabled(true)//调试的时候请打开日志,方便排错 .setPlayerFactory(IjkPlayerFactory.create()) .build()); 切换视频内核处理代码 @SuppressLint("SetTextI18n") private void setChangeVideoType(@ConstantKeys.PlayerType int type){ //切换播放核心,不推荐这么做,我这么写只是为了方便测试 VideoPlayerConfig config = VideoViewManager.getConfig(); try { Field mPlayerFactoryField = config.getClass().getDeclaredField("mPlayerFactory"); mPlayerFactoryField.setAccessible(true); PlayerFactory playerFactory = null; switch (type) { case ConstantKeys.VideoPlayerType.TYPE_IJK: playerFactory = IjkPlayerFactory.create(); mTvTitle.setText("视频内核:" + " (IjkPlayer)"); break; case ConstantKeys.VideoPlayerType.TYPE_EXO: playerFactory = ExoPlayerFactory.create(); mTvTitle.setText("视频内核:" + " (ExoPlayer)"); break; case ConstantKeys.VideoPlayerType.TYPE_NATIVE: playerFactory = MediaPlayerFactory.create(); mTvTitle.setText("视频内核:" + " (MediaPlayer)"); break; case ConstantKeys.VideoPlayerType.TYPE_RTC: break; } mPlayerFactoryField.set(config, playerFactory); } catch (Exception e) { e.printStackTrace(); } } 03.切换视频模式 关于全屏模式相关api //进入全屏 mVideoPlayer.startFullScreen(); //退出全屏 mVideoPlayer.stopFullScreen(); 关于小窗口播放相关api //开启小屏 mVideoPlayer.startTinyScreen(); //退出小屏 mVideoPlayer.stopTinyScreen(); 04.切换视频清晰度 05.视频播放监听 这个分为两部分:第一部分是播放模式监听,第二部分是播放状态监听,暴露给开发者。这里不建议使用0,1,非常不方便简明之意,采用注解限定。 mVideoPlayer.setOnStateChangeListener(new OnVideoStateListener() { /** * 播放模式 * 普通模式,小窗口模式,正常模式三种其中一种 * MODE_NORMAL 普通模式 * MODE_FULL_SCREEN 全屏模式 * MODE_TINY_WINDOW 小屏模式 * @param playerState 播放模式 */ @Override public void onPlayerStateChanged(int playerState) { switch (playerState) { case ConstantKeys.PlayMode.MODE_NORMAL: //普通模式 break; case ConstantKeys.PlayMode.MODE_FULL_SCREEN: //全屏模式 break; case ConstantKeys.PlayMode.MODE_TINY_WINDOW: //小屏模式 break; } } /** * 播放状态 * -1 播放错误 * 0 播放未开始 * 1 播放准备中 * 2 播放准备就绪 * 3 正在播放 * 4 暂停播放 * 5 正在缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,缓冲区数据足够后恢复播放) * 6 暂停缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,此时暂停播放器,继续缓冲,缓冲区数据足够后恢复暂停 * 7 播放完成 * 8 开始播放中止 * @param playState 播放状态,主要是指播放器的各种状态 */ @Override public void onPlayStateChanged(int playState) { switch (playState) { case ConstantKeys.CurrentState.STATE_IDLE: //播放未开始,初始化 break; case ConstantKeys.CurrentState.STATE_START_ABORT: //开始播放中止 break; case ConstantKeys.CurrentState.STATE_PREPARING: //播放准备中 break; case ConstantKeys.CurrentState.STATE_PREPARED: //播放准备就绪 break; case ConstantKeys.CurrentState.STATE_ERROR: //播放错误 break; case ConstantKeys.CurrentState.STATE_BUFFERING_PLAYING: //正在缓冲 break; case ConstantKeys.CurrentState.STATE_PLAYING: //正在播放 break; case ConstantKeys.CurrentState.STATE_PAUSED: //暂停播放 break; case ConstantKeys.CurrentState.STATE_BUFFERING_PAUSED: //暂停缓冲 break; case ConstantKeys.CurrentState.STATE_COMPLETED: //播放完成 break; } } }); 06.在列表中播放 第一步:初始化视频播放器,创建VideoPlayer对象 mVideoView = new VideoPlayer(context); mVideoView.setOnStateChangeListener(new VideoPlayer.SimpleOnStateChangeListener() { @Override public void onPlayStateChanged(int playState) { //监听VideoViewManager释放,重置状态 if (playState == ConstantKeys.CurrentState.STATE_IDLE) { PlayerUtils.removeViewFormParent(mVideoView); mLastPos = mCurPos; mCurPos = -1; } } }); mController = new BasisVideoController(context); mVideoView.setController(mController); 第二步:设置RecyclerView和Adapter mAdapter.setOnItemChildClickListener(new OnItemChildClickListener() { @Override public void onItemChildClick(int position) { //点击item播放视频 startPlay(position); } }); mRecyclerView.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { @Override public void onChildViewAttachedToWindow(@NonNull View view) { } @Override public void onChildViewDetachedFromWindow(@NonNull View view) { FrameLayout playerContainer = view.findViewById(R.id.player_container); View v = playerContainer.getChildAt(0); if (v != null && v == mVideoView && !mVideoView.isFullScreen()) { //销毁视频 releaseVideoView(); } } }); 第三步:播放视频和销毁视频的逻辑代码 /** * 开始播放 * @param position 列表位置 */ protected void startPlay(int position) { if (mCurPos == position) return; if (mCurPos != -1) { releaseVideoView(); } VideoInfoBean videoBean = mVideos.get(position); mVideoView.setUrl(videoBean.getVideoUrl()); View itemView = mLinearLayoutManager.findViewByPosition(position); if (itemView == null) return; VideoRecyclerViewAdapter.VideoHolder viewHolder = (VideoRecyclerViewAdapter.VideoHolder) itemView.getTag(); //把列表中预置的PrepareView添加到控制器中,注意isPrivate此处只能为true。 mController.addControlComponent(viewHolder.mPrepareView, true); PlayerUtils.removeViewFormParent(mVideoView); viewHolder.mPlayerContainer.addView(mVideoView, 0); //播放之前将VideoView添加到VideoViewManager以便在别的页面也能操作它 VideoViewManager.instance().add(mVideoView, "list"); mVideoView.start(); mCurPos = position; } private void releaseVideoView() { mVideoView.release(); if (mVideoView.isFullScreen()) { mVideoView.stopFullScreen(); } if(getActivity().getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } mCurPos = -1; } 08.其他重要功能Api 设置视频播放器背景图,和视频标题。 //注意,下面这个controller是指BasisVideoController //设置视频背景图 ImageView thumb = controller.getThumb(); Glide.with(this).load(R.drawable.image_default).into(controller.getThumb()); //设置视频标题 controller.setTitle("视频标题"); 判断是否锁屏 //判断是否锁屏 boolean locked = controller.isLocked(); //设置是否锁屏 controller.setLocked(true); 设置播放视频缩放类型。借鉴于网络博客,类似图片缩放。建议选择16:9类型,最常见 mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_16_9); mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_DEFAULT); mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_4_3); mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_MATCH_PARENT); mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_ORIGINAL); mVideoPlayer.setScreenScaleType(ConstantKeys.PlayerScreenScaleType.SCREEN_SCALE_CENTER_CROP); 09.播放多个视频 这个举一个例子,比如同时播放两个视频,当然这种情况在app中可能比较少 //必须设置 player1.setUrl(VOD_URL_1); VideoPlayerBuilder.Builder builder = VideoPlayerBuilder.newBuilder(); builder.setEnableAudioFocus(false); VideoPlayerBuilder videoPlayerBuilder = new VideoPlayerBuilder(builder); player1.setVideoBuilder(videoPlayerBuilder); BasisVideoController controller1 = new BasisVideoController(this); player1.setController(controller1); mVideoViews.add(player1); //必须设置 player2.setUrl(VOD_URL_2); VideoPlayerBuilder.Builder builder2 = VideoPlayerBuilder.newBuilder(); builder.setEnableAudioFocus(false); VideoPlayerBuilder videoPlayerBuilder2 = new VideoPlayerBuilder(builder2); player2.setVideoBuilder(videoPlayerBuilder2); BasisVideoController controller2 = new BasisVideoController(this); player2.setController(controller2); mVideoViews.add(player2); 那么要是页面切换到后台,如何处理多个视频的暂停功能呢?如下所示: @Override protected void onPause() { super.onPause(); for (VideoPlayer vv : mVideoViews) { vv.pause(); } } @Override protected void onResume() { super.onResume(); for (VideoPlayer vv : mVideoViews) { vv.pause(); } } @Override protected void onDestroy() { super.onDestroy(); for (VideoPlayer vv : mVideoViews) { vv.release(); } } @Override public void onBackPressed() { for (VideoPlayer vv : mVideoViews) { if (vv.onBackPressed()) return; } super.onBackPressed(); } 10.VideoPlayer相关Api 关于视频播放相关的api如下所示 //暂停播放 mVideoPlayer.pause(); //视频缓冲完毕,准备开始播放时回调 mVideoPlayer.onPrepared(); //重新播放 mVideoPlayer.replay(true); //继续播放 mVideoPlayer.resume(); //调整播放进度 mVideoPlayer.seekTo(100); //循环播放, 默认不循环播放 mVideoPlayer.setLooping(true); //设置播放速度 mVideoPlayer.setSpeed(1.1f); //设置音量 0.0f-1.0f 之间 mVideoPlayer.setVolume(1,1); //开始播放 mVideoPlayer.start(); 关于视频切换播放模式相关api //判断是否处于全屏状态 boolean fullScreen = mVideoPlayer.isFullScreen(); //是否是小窗口模式 boolean tinyScreen = mVideoPlayer.isTinyScreen(); //进入全屏 mVideoPlayer.startFullScreen(); //退出全屏 mVideoPlayer.stopFullScreen(); //开启小屏 mVideoPlayer.startTinyScreen(); //退出小屏 mVideoPlayer.stopTinyScreen(); 关于其他比如获取速度,音量,设置属性相关Api //VideoPlayer相关 VideoPlayerBuilder.Builder builder = VideoPlayerBuilder.newBuilder(); VideoPlayerBuilder videoPlayerBuilder = new VideoPlayerBuilder(builder); //设置视频播放器的背景色 builder.setPlayerBackgroundColor(Color.BLACK); //设置小屏的宽高 int[] mTinyScreenSize = {0, 0}; builder.setTinyScreenSize(mTinyScreenSize); //是否开启AudioFocus监听, 默认开启 builder.setEnableAudioFocus(false); mVideoPlayer.setVideoBuilder(videoPlayerBuilder); //截图 Bitmap bitmap = mVideoPlayer.doScreenShot(); //移除所有播放状态监听 mVideoPlayer.clearOnStateChangeListeners(); //获取当前缓冲百分比 int bufferedPercentage = mVideoPlayer.getBufferedPercentage(); //获取当前播放器的状态 int currentPlayerState = mVideoPlayer.getCurrentPlayerState(); //获取当前的播放状态 int currentPlayState = mVideoPlayer.getCurrentPlayState(); //获取当前播放的位置 long currentPosition = mVideoPlayer.getCurrentPosition(); //获取视频总时长 long duration = mVideoPlayer.getDuration(); //获取倍速速度 float speed = mVideoPlayer.getSpeed(); //获取缓冲速度 long tcpSpeed = mVideoPlayer.getTcpSpeed(); //获取视频宽高 int[] videoSize = mVideoPlayer.getVideoSize(); //是否处于静音状态 boolean mute = mVideoPlayer.isMute(); 11.Controller相关Api Controller控制器相关的Api说明 //设置视频背景图 ImageView thumb = controller.getThumb(); Glide.with(this).load(R.drawable.image_default).into(controller.getThumb()); //设置视频标题 controller.setTitle("视频标题"); //添加自定义视图。每添加一个视图,都是方式层级树的最上层 CustomErrorView customErrorView = new CustomErrorView(this); controller.addControlComponent(customErrorView); //移除控制组件 controller.removeControlComponent(customErrorView); //移除所有的组件 controller.removeAllControlComponent(); //隐藏播放视图 controller.hide(); //显示播放视图 controller.show(); //是否开启根据屏幕方向进入/退出全屏 controller.setEnableOrientation(true); //显示移动网络播放提示 controller.showNetWarning(); //刘海的高度 int cutoutHeight = controller.getCutoutHeight(); //是否有刘海屏 boolean b = controller.hasCutout(); //设置是否适配刘海屏 controller.setAdaptCutout(true); //停止刷新进度 controller.stopProgress(); //开始刷新进度,注意:需在STATE_PLAYING时调用才会开始刷新进度 controller.startProgress(); //判断是否锁屏 boolean locked = controller.isLocked(); //设置是否锁屏 controller.setLocked(true); //取消计时 controller.stopFadeOut(); //开始计时 controller.startFadeOut(); //设置播放视图自动隐藏超时 controller.setDismissTimeout(8); //销毁 controller.destroy(); 12.边播放边缓存api 如下所示 controller = new BasisVideoController(this); //设置视频背景图 Glide.with(this).load(R.drawable.image_default).into(controller.getThumb()); //设置控制器 mVideoPlayer.setController(controller); HttpProxyCacheServer server = new HttpProxyCacheServer(this); String proxyVideoUrl = server.getProxyUrl(URL_AD); mVideoPlayer.setUrl(proxyUrl); mVideoPlayer.start(); 13.类似抖音视频预加载 如下所示,这个是针对ViewPager //获取PreloadManager预加载管理者对象 mPreloadManager = PreloadManager.getInstance(this); //在播放视频的时候 String playUrl = mPreloadManager.getPlayUrl(url); VideoLogUtils.i("startPlay: " + "position: " + position + " url: " + playUrl); mVideoPlayer.setUrl(playUrl); //在页面滚动的时候 mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() { @Override public void onPageScrollStateChanged(int state) { super.onPageScrollStateChanged(state); if (state == VerticalViewPager.SCROLL_STATE_IDLE) { mPreloadManager.resumePreload(mCurPos, mIsReverseScroll); } else { mPreloadManager.pausePreload(mCurPos, mIsReverseScroll); } } }); 如下所示,这个是针对RecyclerView recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { /** * 是否反向滑动 */ private boolean mIsReverseScroll; @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); if (dy>0){ //表示下滑 mIsReverseScroll = false; } else { //表示上滑 mIsReverseScroll = true; } } @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { super.onScrollStateChanged(recyclerView, newState); if (newState == VerticalViewPager.SCROLL_STATE_IDLE) { mPreloadManager.resumePreload(mCurPos, mIsReverseScroll); } else { mPreloadManager.pausePreload(mCurPos, mIsReverseScroll); } } }); 14.视频播放器埋点 代码如下所示,写一个类,实现BuriedPointEvent即可。即可埋点视频的播放次数,播放进度,点击视频广告啥的,方便统一管理 public class BuriedPointEventImpl implements BuriedPointEvent { /** * 进入视频播放 * @param url 视频url */ @Override public void playerIn(String url) { } /** * 退出视频播放 * @param url 视频url */ @Override public void playerDestroy(String url) { } /** * 视频播放完成 * @param url 视频url */ @Override public void playerCompletion(String url) { } /** * 视频播放异常 * @param url 视频url * @param isNetError 是否是网络异常 */ @Override public void onError(String url, boolean isNetError) { } /** * 点击了视频广告 * @param url 视频url */ @Override public void clickAd(String url) { } /** * 退出视频播放时候的播放进度百度分 * @param url 视频url * @param progress 视频进度,计算百分比【退出时候进度 / 总进度】 */ @Override public void playerOutProgress(String url, float progress) { } /** * 视频切换音频 * @param url 视频url */ @Override public void videoToMedia(String url) { } } 15.播放器示例展示图
02.视频播放器整体结构 目录介绍 01.视频常见的布局视图 02.后期可能涉及的视图 03.需要达到的目的和效果 04.视频视图层级示意图 05.整体架构思路分析流程 06.如何创建不同播放器 07.如何友好处理播放器UI 08.交互交给外部开发者 09.关于优先级视图展示 10.代码项目lib代码介绍 00.视频播放器通用框架 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 该播放器整体架构:播放器内核(自由切换) + 视频播放器 + 边播边缓存 + 高度定制播放器UI视图层 项目地址:https://github.com/yangchong211/YCVideoPlayer 关于视频播放器整体功能介绍文档:https://juejin.im/post/6883457444752654343 01.视频常见的布局视图 视频底图(用于显示初始化视频时的封面图),视频状态视图【加载loading,播放异常,加载视频失败,播放完成等】 改变亮度和声音【改变声音视图,改变亮度视图】,改变视频快进和快退,左右滑动快进和快退视图(手势滑动的快进快退提示框) 顶部控制区视图(包含返回健,title等),底部控制区视图(包含进度条,播放暂停,时间,切换全屏等) 锁屏布局视图(全屏时展示,其他隐藏),底部播放进度条视图(很多播放器都有这个),清晰度列表视图(切换清晰度弹窗) 底部播放进度条视图(很多播放器都有这个),当bottom视图显示时底部进度条隐藏,反之则显示 02.后期可能涉及的视图 手势指导页面(有些播放器有新手指导功能),离线下载的界面(该界面中包含下载列表, 列表的item编辑(全选, 删除)) 用户从wifi切换到4g网络,提示网络切换弹窗界面(当网络由wifi变为4g的时候会显示) 图片广告视图(带有倒计时消失),开始视频广告视图,非会员试看视图 弹幕视图(这个很重要),水印显示视图,倍速播放界面(用于控制倍速),底部视频列表缩略图视图 投屏视频视图界面,视频直播间刷礼物界面,老师开课界面,展示更多视图(下载,分享,切换音频等) 03.需要达到的目的和效果 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 04.视频视图层级示意图 05.整体架构思路分析流程 播放器内核 可以切换ExoPlayer、MediaPlayer,IjkPlayer,声网视频播放器,这里使用工厂模式Factory + AbstractVideoPlayer + 各个实现AbstractVideoPlayer抽象类的播放器类 定义抽象的播放器,主要包含视频初始化,设置,状态设置,以及播放监听。由于每个内核播放器api可能不一样,所以这里需要实现AbstractVideoPlayer抽象类的播放器类,方便后期统一调用 为了方便创建不同内核player,所以需要创建一个PlayerFactory,定义一个createPlayer创建播放器的抽象方法,然后各个内核都实现它,各自创建自己的播放器 VideoPlayer播放器 可以自由切换视频内核,Player+Controller。player负责播放的逻辑,Controller负责视图相关的逻辑,两者之间用接口进行通信 针对Controller,需要定义一个接口,主要负责视图UI处理逻辑,支持添加各种自定义视图View【统一实现自定义接口Control】,每个view尽量保证功能单一性,最后通过addView形式添加进来 针对Player,需要定义一个接口,主要负责视频播放处理逻辑,比如视频播放,暂停,设置播放进度,设置视频链接,切换播放模式等操作。需要注意把Controller设置到Player里面,两者之间通过接口交互 UI控制器视图 定义一个BaseVideoController类,这个主要是集成各种事件的处理逻辑,比如播放器状态改变,控制视图隐藏和显示,播放进度改变,锁定状态改变,设备方向监听等等操作 定义一个view的接口InterControlView,在这里类里定义绑定视图,视图隐藏和显示,播放状态,播放模式,播放进度,锁屏等操作。这个每个实现类则都可以拿到这些属性呢 在BaseVideoController中使用LinkedHashMap保存每个自定义view视图,添加则put进来后然后通过addView将视图添加到该控制器中,这样非常方便添加自定义视图 播放器切换状态需要改变Controller视图,比如视频异常则需要显示异常视图view,则它们之间的交互是通过ControlWrapper(同时实现Controller接口和Player接口)实现 06.如何创建不同播放器 目标要求 基础播放器封装了包含ExoPlayer、MediaPlayer,ijkPlayer,声网视频播放器等 可以自由切换初始化任何一种视频播放器,比如通过构造传入类型参数来创建不同的视频播放器 PlayerFactory playerFactory = IjkPlayerFactory.create(); IjkVideoPlayer ijkVideoPlayer = (IjkVideoPlayer) playerFactory.createPlayer(this); PlayerFactory playerFactory = ExoPlayerFactory.create(); ExoMediaPlayer exoMediaPlayer = (ExoMediaPlayer) playerFactory.createPlayer(this); PlayerFactory playerFactory = MediaPlayerFactory.create(); AndroidMediaPlayer androidMediaPlayer = (AndroidMediaPlayer) playerFactory.createPlayer(this); 使用那种形式创建播放器 工厂模式 隐藏内核播放器创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节即可创建播放器。符合开闭原则 适配器模式 这个也是事后补救模式,但是在该库中,没有尝试这种方式。https://www.runoob.com/design-pattern/adapter-pattern.html 如何做到内核无缝切换? 具体的代码案例,以及具体做法,在下一篇博客中会介绍到。或者直接看代码:视频播放器 播放器内核的架构图如下所示 07.如何友好处理播放器UI 发展中遇到的问题 播放器可支持多种场景下的播放,多个产品会用到同一个播放器,这样就会带来一个问题,一个播放业务播放器状态发生变化,其他播放业务必须同步更新播放状态,各个播放业务之间互相交叉,随着播放业务的增多,开发和维护成本会急剧增加, 导致后续开发不可持续。 播放器内核和UI层耦合 也就是说视频player和ui操作柔和到了一起,尤其是两者之间的交互。比如播放中需要更新UI进度条,播放异常需要显示异常UI,都比较难处理播放器状态变化更新UI操作 UI难以自定义或者修改麻烦 比如常见的视频播放器,会把视频各种视图写到xml中,这种方式在后期代码会很大,而且改动一个小的布局,则会影响大。这样到后期往往只敢加代码,而不敢删除代码…… 有时候难以适应新的场景,比如添加一个播放广告,老师开课,或者视频引导业务需求,则需要到播放器中写一堆业务代码。迭代到后期,违背了开闭原则,视频播放器需要做到和业务分离 视频播放器结构需要清晰 这个是指该视频播放器能否看了文档后快速上手,知道封装的大概流程。方便后期他人修改和维护,因此需要将视频播放器功能分离。比如切换内核+视频播放器(player+controller+view) 一定要解耦合 播放器player与视频UI解耦:支持添加自定义视频视图,比如支持添加自定义广告,新手引导,或者视频播放异常等视图,这个需要较强的拓展性 适合多种业务场景 比如适合播放单个视频,多个视频,以及列表视频,或者类似抖音那种一个页面一个视频,还有小窗口播放视频。也就是适合大多数业务场景 具体操作 播放状态变化是导致不同播放业务场景之间交叉同步,解除播放业务对播放器的直接操控,采用接口监听进行解耦。比如:player+controller+interface 具体的代码案例,以及具体做法,在下一篇博客中会介绍到。或者直接看代码:视频播放器 08.交互交给外部开发者 在播放器中,很重要一个就是需要把播放器player的播放模式(小屏幕,正常,全屏模式),以及播放状态(播放,暂停,异常,完成,加载,缓冲等多种状态)暴露给控制层view,方便做UI更新。 比如外部开发者想加一个广告视图,这个时候肯定需要给它播放器的状态 添加了自定义播放器视图,比如添加视频广告,可以选择跳过,选择播放暂停。那这个视图view,肯定是需要操作player或者获取player的状态的。这个时候就需要暴露监听视频播放的状态接口监听 首先定义一个InterControlView接口,也就是说所有自定义视频视图view需要实现这个接口,该接口中的核心方法有:绑定视图到播放器,视图显示隐藏变化监听,播放状态监听,播放模式监听,进度监听,锁屏监听等 在BaseVideoController中的状态监听中,通过InterControlView接口对象就可以把播放器的状态传递到子类中 举一个代码的例子 比如,现在有个业务需求,需要在视频播放器刚开始添加一个广告视图,等待广告倒计时120秒后,直接进入播放视频逻辑。相信这个业务场景很常见,大家都碰到过,使用该播放器就特别简单,代码如下所示: 首先创建一个自定义view,需要实现InterControlView接口,重写该接口中所有抽象方法,这里省略了很多代码,具体看demo。 public class AdControlView extends FrameLayout implements InterControlView, View.OnClickListener { private ControlWrapper mControlWrapper; public AdControlView(@NonNull Context context) { super(context); init(context); } private void init(Context context){ LayoutInflater.from(getContext()).inflate(R.layout.layout_ad_control_view, this, true); } /** * 播放状态 * -1 播放错误 * 0 播放未开始 * 1 播放准备中 * 2 播放准备就绪 * 3 正在播放 * 4 暂停播放 * 5 正在缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,缓冲区数据足够后恢复播放) * 6 暂停缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,此时暂停播放器,继续缓冲,缓冲区数据足够后恢复暂停 * 7 播放完成 * 8 开始播放中止 * @param playState 播放状态,主要是指播放器的各种状态 */ @Override public void onPlayStateChanged(int playState) { switch (playState) { case ConstantKeys.CurrentState.STATE_PLAYING: mControlWrapper.startProgress(); mPlayButton.setSelected(true); break; case ConstantKeys.CurrentState.STATE_PAUSED: mPlayButton.setSelected(false); break; } } /** * 播放模式 * 普通模式,小窗口模式,正常模式三种其中一种 * MODE_NORMAL 普通模式 * MODE_FULL_SCREEN 全屏模式 * MODE_TINY_WINDOW 小屏模式 * @param playerState 播放模式 */ @Override public void onPlayerStateChanged(int playerState) { switch (playerState) { case ConstantKeys.PlayMode.MODE_NORMAL: mBack.setVisibility(GONE); mFullScreen.setSelected(false); break; case ConstantKeys.PlayMode.MODE_FULL_SCREEN: mBack.setVisibility(VISIBLE); mFullScreen.setSelected(true); break; } //暂未实现全面屏适配逻辑,需要你自己补全 } } 然后该怎么使用这个自定义view呢?很简单,在之前基础上,通过控制器对象add进来即可,代码如下所示 controller = new BasisVideoController(this); AdControlView adControlView = new AdControlView(this); adControlView.setListener(new AdControlView.AdControlListener() { @Override public void onAdClick() { BaseToast.showRoundRectToast( "广告点击跳转"); } @Override public void onSkipAd() { playVideo(); } }); controller.addControlComponent(adControlView); //设置控制器 mVideoPlayer.setController(controller); mVideoPlayer.setUrl(proxyUrl); mVideoPlayer.start(); 09.关于优先级视图展示 视频播放器为了拓展性,需要暴露view接口供外部开发者自定义视频播放器视图,通过addView的形式添加到播放器的控制器中。 这就涉及view视图的层级性。控制view视图的显示和隐藏是特别重要的,这个时候在自定义view中就需要拿到播放器的状态 举一个简单的例子,基础视频播放器 添加了基础播放功能的几个播放视图。有播放完成,播放异常,播放加载,顶部标题栏,底部控制条栏,锁屏,以及手势滑动栏。如何控制它们的显示隐藏切换呢? 在addView这些视图时,大多数的view都是默认GONE隐藏的。比如当视频初始化时,先缓冲则显示缓冲view而隐藏其他视图,接着播放则显示顶部/底部视图而隐藏其他视图 比如有时候需要显示两种不同的自定义视图如何处理 举个例子,播放的时候,点击一下视频,会显示顶部title视图和底部控制条视图,那么这样会同时显示两个视图。 点击顶部title视图的返回键可以关闭播放器,点击底部控制条视图的播放暂停可以控制播放条件。这个时候底部控制条视图FrameLayout的ChildView在整个视频的底部,顶部title视图FrameLayout的ChildView在整个视频的顶部,这样可以达到上下层都可以相应事件。 那么FrameLayout层层重叠,如何让下层不响应事件 在最上方显示的层加上: android:clickable="true" 可以避免点击上层触发底层。或者直接给控制设置一个background颜色也可以。 10.代码项目lib代码介绍
视频播放器介绍文档 目录介绍 01.该视频播放器介绍 02.视频播放器功能 03.视频播放器架构说明 04.视频播放器如何使用 05.播放器详细Api文档 06.播放器封装思路 07.播放器示例展示图 08.添加自定义视图 09.视频播放器优化处理 10.播放器问题记录说明 11.性能优化和库大小 12.视频缓存原理介绍 13.查看视频播放器日志 14.该库异常code说明 15.该库系列wiki文档 16.版本更新文档记录 00.视频播放器通用框架 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,音频播放,播放回放,以及视频直播的功能。使用简单,代码拓展性强,封装性好,主要是和业务彻底解耦,暴露接口监听给开发者处理业务具体逻辑 该播放器整体架构:播放器内核(自由切换) + 视频播放器 + 边播边缓存 + 高度定制播放器UI视图层 01.该视频播放器介绍 1.1 该库说明 播放器功能 MediaPlayer ExoPlayer IjkPlayer RTC TXPlayer UI/Player/业务解耦 支持 支持 支持 切换视频播放模式 支持 支持 支持 视频无缝切换 支持 支持 支持 调节播放进度 支持 支持 支持 网络环境监听 支持 支持 支持 滑动改变亮度/声音 支持 支持 支持 设置视频播放比例 支持 支持 支持 自由切换视频内核 支持 支持 支持 记录播放位置 支持 支持 支持 清晰度模式切换 支持 支持 支持 重力感应自动进入 支持 支持 支持 锁定屏幕功能 支持 支持 支持 倍速播放 不支持 支持 支持 视频小窗口播放 支持 支持 支持 列表小窗口播放 支持 支持 支持 边播边缓存 支持 支持 支持 同时播放多个视频 支持 支持 支持 仿快手预加载 支持 支持 支持 基于内核无UI 支持 支持 支持 添加弹幕 支持 支持 支持 全屏显示电量 支持 支持 支持 1.2 该库功能说明 类型 功能说明 项目结构 VideoCache缓存lib,VideoKernel视频内核lib,VideoPlayer视频UIlib 内核 MediaPlayer、ExoPlayer、IjkPlayer,后期接入Rtc和TXPlayer 协议/格式 http/https、concat、rtsp、hls、rtmp、file、m3u8、mkv、webm、mp3、mp4等 画面 调整显示比例:默认、16:9、4:3、填充;播放时旋转画面角度(0,90,180,270);镜像旋转 布局 内核和UI分离,和市面GitHub上大多数播放器不一样,方便定制,通过addView添加 播放 正常播放,小窗播放,列表播放,仿抖音播放 自定义 可以自定义添加视频UI层,可以说UI和Player高度分离,支持自定义渲染层SurfaceView 02.视频播放器功能 A基础功能 A.1.1 能够自定义视频加载loading类型,设置视频标题,设置视频底部图片,设置播放时长等基础功能 A.1.2 可以切换播放器的视频播放状态,播放错误,播放未开始,播放开始,播放准备中,正在播放,暂停播放,正在缓冲等等状态 A.1.3 可以自由设置播放器的播放模式,比如,正常播放,全屏播放,和小屏幕播放。其中全屏播放支持旋转屏幕。 A.1.4 可以支持多种视频播放类型,比如,原生封装视频播放器,还有基于ijkPlayer封装的播放器。 A.1.5 可以设置是否隐藏播放音量,播放进度,播放亮度等,可以通过拖动seekBar改变视频进度。还支持设置n秒后不操作则隐藏头部和顶部布局功能 A.1.6 可以设置竖屏模式下全屏模式和横屏模式下的全屏模式,方便多种使用场景 A.1.7 top和bottom面版消失和显示:点击视频画面会显示、隐藏操作面板;显示后不操作会5秒后自动消失【也可以设置n秒消失时间】 B高级功能 B.1.1 支持一遍播放一遍缓冲的功能,其中缓冲包括两部分,第一种是播放过程中缓冲,第二种是暂停过程中缓冲 B.1.2 基于ijkPlayer,ExoPlayer,Rtc,原生MediaPlayer等的封装播放器,支持多种格式视频播放 B.1.3 可以设置是否记录播放位置,设置播放速度,设置屏幕比例 B.1.4 支持滑动改变音量【屏幕右边】,改变屏幕亮度【屏幕左边】,屏幕底测左右滑动调节进度 B.1.5 支持list页面中视频播放,滚动后暂停播放,播放可以自由设置是否记录状态。并且还支持删除视频播放位置状态。 B.1.6 切换横竖屏:切换全屏时,隐藏状态栏,显示自定义top(显示电量);竖屏时恢复原有状态 B.1.7 支持切换视频清晰度模式 B.1.8 添加锁屏功能,竖屏不提供锁屏按钮,横屏全屏时显示,并且锁屏时,屏蔽手势处理 C拓展功能【这块根据实际情况选择是否需要使用,一般视频付费App会有这个工鞥】 C1产品需求:类似优酷,爱奇艺视频播放器部分逻辑。比如如果用户没有登录也没有看视频权限,则提示试看视频[自定义布局];如果用户没有登录但是有看视频权限,则正常观看;如果用户登录,但是没有充值会员,部分需要权限视频则进入试看模式,试看结束后弹出充值会员界面;如果用户余额不足,比如余额只有99元,但是视频观看要199元,则又有其他提示。 C2自身需求:比如封装好了视频播放库,那么点击视频上登录按钮则跳到登录页面;点击充值会员页面也跳到充值页面。这个通过定义接口,可以让使用者通过方法调用,灵活处理点击事件。 C.1.1 可以设置试看模式,设置试看时长。试看结束后就提示登录或者充值…… C.1.2 对于设置视频的宽高,建议设置成4:3或者16:9或者常用比例,如果不是常用比例,则可能会有黑边。其中黑边的背景可以设置 C.1.3 可以设置播放有权限的视频时的各种文字描述,而没有把它写在封装库中,使用者自己设定 C.1.4 锁定屏幕功能,这个参考大部分播放器,只有在全屏模式下才会有 03.视频播放器架构说明 视频常见的布局视图 视频底图(用于显示初始化视频时的封面图),视频状态视图【加载loading,播放异常,加载视频失败,播放完成等】 改变亮度和声音【改变声音视图,改变亮度视图】,改变视频快进和快退,左右滑动快进和快退视图(手势滑动的快进快退提示框) 顶部控制区视图(包含返回健,title等),底部控制区视图(包含进度条,播放暂停,时间,切换全屏等) 锁屏布局视图(全屏时展示,其他隐藏),底部播放进度条视图(很多播放器都有这个),清晰度列表视图(切换清晰度弹窗) 后期可能涉及的布局视图 手势指导页面(有些播放器有新手指导功能),离线下载的界面(该界面中包含下载列表, 列表的item编辑(全选, 删除)) 用户从wifi切换到4g网络,提示网络切换弹窗界面(当网络由wifi变为4g的时候会显示) 图片广告视图(带有倒计时消失),开始视频广告视图,非会员试看视图 弹幕视图(这个很重要),水印显示视图,倍速播放界面(用于控制倍速),底部视频列表缩略图视图 投屏视频视图界面,视频直播间刷礼物界面,老师开课界面,展示更多视图(下载,分享,切换音频等) 视频播放器的痛点 播放器内核难以切换 不同的视频播放器内核,由于api不一样,所以难以切换操作。要是想兼容内核切换,就必须自己制定一个视频接口+实现类的播放器 播放器内核和UI层耦合 也就是说视频player和ui操作柔和到了一起,尤其是两者之间的交互。比如播放中需要更新UI进度条,播放异常需要显示异常UI,都比较难处理播放器状态变化更新UI操作 UI难以自定义或者修改麻烦 比如常见的视频播放器,会把视频各种视图写到xml中,这种方式在后期代码会很大,而且改动一个小的布局,则会影响大。这样到后期往往只敢加代码,而不敢删除代码…… 有时候难以适应新的场景,比如添加一个播放广告,老师开课,或者视频引导业务需求,则需要到播放器中写一堆业务代码。迭代到后期,违背了开闭原则,视频播放器需要做到和业务分离 视频播放器结构不清晰 这个是指该视频播放器能否看了文档后快速上手,知道封装的大概流程。方便后期他人修改和维护,因此需要将视频播放器功能分离。比如切换内核+视频播放器(player+controller+view) 需要达到的目的和效果 基础封装视频播放器player,可以在ExoPlayer、MediaPlayer,声网RTC视频播放器内核,原生MediaPlayer可以自由切换 对于视图状态切换和后期维护拓展,避免功能和业务出现耦合。比如需要支持播放器UI高度定制,而不是该lib库中UI代码 针对视频播放,视频投屏,音频播放,播放回放,以及视频直播的功能 通用视频框架特点 一定要解耦合 播放器内核与播放器解耦: 支持更多的播放场景、以及新的播放业务快速接入,并且不影响其他播放业务,比如后期添加阿里云播放器内核,或者腾讯播放器内核 播放器player与视频UI解耦:支持添加自定义视频视图,比如支持添加自定义广告,新手引导,或者视频播放异常等视图,这个需要较强的拓展性 适合多种业务场景 比如适合播放单个视频,多个视频,以及列表视频,或者类似抖音那种一个页面一个视频,还有小窗口播放视频。也就是适合大多数业务场景 视频分层 播放器内核 可以切换ExoPlayer、MediaPlayer,IjkPlayer,声网视频播放器,这里使用工厂模式Factory + AbstractVideoPlayer + 各个实现AbstractVideoPlayer抽象类的播放器类 定义抽象的播放器,主要包含视频初始化,设置,状态设置,以及播放监听。由于每个内核播放器api可能不一样,所以这里需要实现AbstractVideoPlayer抽象类的播放器类,方便后期统一调用 为了方便创建不同内核player,所以需要创建一个PlayerFactory,定义一个createPlayer创建播放器的抽象方法,然后各个内核都实现它,各自创建自己的播放器 VideoPlayer播放器 可以自由切换视频内核,Player+Controller。player负责播放的逻辑,Controller负责视图相关的逻辑,两者之间用接口进行通信 针对Controller,需要定义一个接口,主要负责视图UI处理逻辑,支持添加各种自定义视图View【统一实现自定义接口Control】,每个view尽量保证功能单一性,最后通过addView形式添加进来 针对Player,需要定义一个接口,主要负责视频播放处理逻辑,比如视频播放,暂停,设置播放进度,设置视频链接,切换播放模式等操作。需要注意把Controller设置到Player里面,两者之间通过接口交互 UI控制器视图 定义一个BaseVideoController类,这个主要是集成各种事件的处理逻辑,比如播放器状态改变,控制视图隐藏和显示,播放进度改变,锁定状态改变,设备方向监听等等操作 定义一个view的接口InterControlView,在这里类里定义绑定视图,视图隐藏和显示,播放状态,播放模式,播放进度,锁屏等操作。这个每个实现类则都可以拿到这些属性呢 在BaseVideoController中使用LinkedHashMap保存每个自定义view视图,添加则put进来后然后通过addView将视图添加到该控制器中,这样非常方便添加自定义视图 播放器切换状态需要改变Controller视图,比如视频异常则需要显示异常视图view,则它们之间的交互是通过ControlWrapper(同时实现Controller接口和Player接口)实现 04.视频播放器如何使用 4.1 关于gradle引用说明 如下所示 //视频UI层,必须要有 implementation 'cn.yc:VideoPlayer:3.0.1' //视频缓存,如果不需要则可以不依赖 implementation 'cn.yc:VideoCache:3.0.0' //视频内核层,必须有 implementation 'cn.yc:VideoKernel:3.0.1' 4.2 在xml中添加布局 注意,在实际开发中,由于Android手机碎片化比较严重,分辨率太多了,建议灵活设置布局的宽高比为4:3或者16:9或者你认为合适的,可以用代码设置。 如果宽高比变形,则会有黑边 <org.yczbj.ycvideoplayerlib.player.VideoPlayer android:id="@+id/video_player" android:layout_width="match_parent" android:layout_height="240dp"/> 4.3 最简单的视频播放器参数设定 如下所示 //创建基础视频播放器,一般播放器的功能 BasisVideoController controller = new BasisVideoController(this); //设置控制器 mVideoPlayer.setVideoController(controller); //设置视频播放链接地址 mVideoPlayer.setUrl(url); //开始播放 mVideoPlayer.start(); 4.4 注意问题 如果是全屏播放,则需要在清单文件中设置当前activity的属性值 android:configChanges 保证了在全屏的时候横竖屏切换不会执行Activity的相关生命周期,打断视频的播放 android:screenOrientation 固定了屏幕的初始方向 这两个变量控制全屏后和退出全屏的屏幕方向 <activity android:name=".VideoActivity" android:configChanges="orientation|keyboardHidden|screenSize" android:screenOrientation="portrait"/> 如何一进入页面就开始播放视频,稍微延时一下即可 代码如下所示,注意避免直接start(),因为有可能视频还没有初始化完成…… mVideoPlayer.postDelayed(new Runnable() { @Override public void run() { mVideoPlayer.start(); } },300); 05.播放器详细Api文档 01.最简单的播放 02.如何切换视频内核 03.切换视频模式 04.切换视频清晰度 05.视频播放监听 06.列表中播放处理 07.悬浮窗口播放 08.其他重要功能Api 09.播放多个视频 10.VideoPlayer相关Api 11.Controller相关Api 12.仿快手播放视频 具体看这篇文档:[视频播放器Api说明]() 06.播放器封装思路 6.1视频层级示例图 6.2 视频播放器流程图 待完善 6.3 视频播放器lib库 6.4 视频内核lib库介绍 6.5视频播放器UI库介绍 07.播放器示例展示图 08.添加自定义视图 比如,现在有个业务需求,需要在视频播放器刚开始添加一个广告视图,等待广告倒计时120秒后,直接进入播放视频逻辑。相信这个业务场景很常见,大家都碰到过,使用该播放器就特别简单,代码如下所示: 首先创建一个自定义view,需要实现InterControlView接口,重写该接口中所有抽象方法,这里省略了很多代码,具体看demo。 public class AdControlView extends FrameLayout implements InterControlView, View.OnClickListener { private ControlWrapper mControlWrapper; public AdControlView(@NonNull Context context) { super(context); init(context); } private void init(Context context){ LayoutInflater.from(getContext()).inflate(R.layout.layout_ad_control_view, this, true); } /** * 播放状态 * -1 播放错误 * 0 播放未开始 * 1 播放准备中 * 2 播放准备就绪 * 3 正在播放 * 4 暂停播放 * 5 正在缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,缓冲区数据足够后恢复播放) * 6 暂停缓冲(播放器正在播放时,缓冲区数据不足,进行缓冲,此时暂停播放器,继续缓冲,缓冲区数据足够后恢复暂停 * 7 播放完成 * 8 开始播放中止 * @param playState 播放状态,主要是指播放器的各种状态 */ @Override public void onPlayStateChanged(int playState) { switch (playState) { case ConstantKeys.CurrentState.STATE_PLAYING: mControlWrapper.startProgress(); mPlayButton.setSelected(true); break; case ConstantKeys.CurrentState.STATE_PAUSED: mPlayButton.setSelected(false); break; } } /** * 播放模式 * 普通模式,小窗口模式,正常模式三种其中一种 * MODE_NORMAL 普通模式 * MODE_FULL_SCREEN 全屏模式 * MODE_TINY_WINDOW 小屏模式 * @param playerState 播放模式 */ @Override public void onPlayerStateChanged(int playerState) { switch (playerState) { case ConstantKeys.PlayMode.MODE_NORMAL: mBack.setVisibility(GONE); mFullScreen.setSelected(false); break; case ConstantKeys.PlayMode.MODE_FULL_SCREEN: mBack.setVisibility(VISIBLE); mFullScreen.setSelected(true); break; } //暂未实现全面屏适配逻辑,需要你自己补全 } } 然后该怎么使用这个自定义view呢?很简单,在之前基础上,通过控制器对象add进来即可,代码如下所示 controller = new BasisVideoController(this); AdControlView adControlView = new AdControlView(this); adControlView.setListener(new AdControlView.AdControlListener() { @Override public void onAdClick() { BaseToast.showRoundRectToast( "广告点击跳转"); } @Override public void onSkipAd() { playVideo(); } }); controller.addControlComponent(adControlView); //设置控制器 mVideoPlayer.setController(controller); mVideoPlayer.setUrl(proxyUrl); mVideoPlayer.start(); 09.视频播放器优化处理 9.1 如何兼容不同内核播放器 提问:针对不同内核播放器,比如谷歌的ExoPlayer,B站的IjkPlayer,还有原生的MediaPlayer,有些api不一样,那使用的时候如何统一api呢? 比如说,ijk和exo的视频播放listener监听api就完全不同,这个时候需要做兼容处理 定义接口,然后各个不同内核播放器实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api 定义一个接口,这个接口有什么呢?这个接口定义通用视频播放器方法,比如常见的有:视频初始化,设置url,加载,以及播放状态,简单来说可以分为三个部分。 第一部分:视频初始化实例对象方法,主要包括:initPlayer初始化视频,setDataSource设置视频播放器地址,setSurface设置视频播放器渲染view,prepareAsync开始准备播放操作 第二部分:视频播放器状态方法,主要包括:播放,暂停,恢复,重制,设置进度,释放资源,获取进度,设置速度,设置音量 第三部分:player绑定view后,需要监听播放状态,比如播放异常,播放完成,播放准备,播放size变化,还有播放准备 首先定义一个工厂抽象类,然后不同的内核播放器分别创建其具体的工厂实现具体类 PlayerFactory:抽象工厂,担任这个角色的是工厂方法模式的核心,任何在模式中创建对象的工厂类必须实现这个接口 ExoPlayerFactory:具体工厂,具体工厂角色含有与业务密切相关的逻辑,并且受到使用者的调用以创建具体产品对象。 如何使用,分为三步,具体操作如下所示 1.先调用具体工厂对象中的方法createPlayer方法;2.根据传入产品类型参数获得具体的产品对象;3.返回产品对象并使用。 简而言之,创建对象的时候只需要传递类型type,而不需要对应的工厂,即可创建具体的产品对象 这种创建对象最大优点 工厂方法用来创建所需要的产品,同时隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体产品类的类名。 加入新的产品时,比如后期新加一个阿里播放器内核,这个时候就只需要添加一个具体工厂和具体产品就可以。系统的可扩展性也就变得非常好,完全符合“开闭原则” 9.2 播放器UI抽取封装优化 发展中遇到的问题 播放器可支持多种场景下的播放,多个产品会用到同一个播放器,这样就会带来一个问题,一个播放业务播放器状态发生变化,其他播放业务必须同步更新播放状态,各个播放业务之间互相交叉,随着播放业务的增多,开发和维护成本会急剧增加, 导致后续开发不可持续。 UI难以自定义或者修改麻烦 比如常见的视频播放器,会把视频各种视图写到xml中,这种方式在后期代码会很大,而且改动一个小的布局,则会影响大。这样到后期往往只敢加代码,而不敢删除代码…… 有时候难以适应新的场景,比如添加一个播放广告,老师开课,或者视频引导业务需求,则需要到播放器中写一堆业务代码。迭代到后期,违背了开闭原则,视频播放器需要做到和业务分离 视频播放器结构需要清晰 也就是说视频player和ui操作柔和到了一起,尤其是两者之间的交互。比如播放中需要更新UI进度条,播放异常需要显示异常UI,都比较难处理播放器状态变化更新UI操作 这个是指该视频播放器能否看了文档后快速上手,知道封装的大概流程。方便后期他人修改和维护,因此需要将视频播放器功能分离。比如切换内核+视频播放器(player+controller+view) 一定要解耦合,播放器player与视频UI解耦:支持添加自定义视频视图,比如支持添加自定义广告,新手引导,或者视频播放异常等视图,这个需要较强的拓展性 适合多种业务场景 比如适合播放单个视频,多个视频,以及列表视频,或者类似抖音那种一个页面一个视频,还有小窗口播放视频。也就是适合大多数业务场景 方便播放业务发生变化 播放状态变化是导致不同播放业务场景之间交叉同步,解除播放业务对播放器的直接操控,采用接口监听进行解耦。比如:player+controller+interface 关于视频播放器 定义一个视频播放器InterVideoPlayer接口,操作视频播放,暂停,缓冲,进度设置,设置播放模式等多种操作。 然后写一个播放器接口的具体实现类,在这个里面拿到内核播放器player,然后做相关的实现操作。 关于视频视图View 定义一个视图InterVideoController接口,主要负责视图显示/隐藏,播放进度,锁屏,状态栏等操作。 然后写一个播放器视图接口的具体实现类,在这里里面inflate视图操作,然后接口方法实现,为了方便后期开发者自定义view,因此需要addView操作,将添加进来的视图用map集合装起来。 播放器player和controller交互 在player中创建BaseVideoController对象,这个时候需要把controller添加到播放器中,这个时候有两个要点特别重要,需要把播放器状态监听,和播放模式监听传递给控制器 setPlayState设置视频播放器播放逻辑状态,主要是播放缓冲,加载,播放中,暂停,错误,完成,异常,播放进度等多个状态,方便控制器做UI更新操作 setPlayerState设置视频播放切换模式状态,主要是普通模式,小窗口模式,正常模式三种其中一种,方便控制器做UI更新 播放器player和view交互 这块非常关键,举个例子,视频播放失败需要显示控制层的异常视图View;播放视频初始化需要显示loading,然后更新UI播放进度条等。都是播放器和视图层交互 可以定义一个类,同时实现InterVideoPlayer接口和InterVideoController接口,这个时候会重新这两个接口所有的方法。此类的目的是为了在InterControlView接口实现类中既能调用VideoPlayer的api又能调用BaseVideoController的api 如何添加自定义播放器视图 添加了自定义播放器视图,比如添加视频广告,可以选择跳过,选择播放暂停。那这个视图view,肯定是需要操作player或者获取player的状态的。这个时候就需要暴露监听视频播放的状态接口监听 首先定义一个InterControlView接口,也就是说所有自定义视频视图view需要实现这个接口,该接口中的核心方法有:绑定视图到播放器,视图显示隐藏变化监听,播放状态监听,播放模式监听,进度监听,锁屏监听等 在BaseVideoController中的状态监听中,通过InterControlView接口对象就可以把播放器的状态传递到子类中 9.4 代码方面优化措施 如果是在Activity中的话,建议设置下面这段代码 @Override protected void onResume() { super.onResume(); if (mVideoPlayer != null) { //从后台切换到前台,当视频暂停时或者缓冲暂停时,调用该方法重新开启视频播放 mVideoPlayer.resume(); } } @Override protected void onPause() { super.onPause(); if (mVideoPlayer != null) { //从前台切到后台,当视频正在播放或者正在缓冲时,调用该方法暂停视频 mVideoPlayer.pause(); } } @Override protected void onDestroy() { super.onDestroy(); if (mVideoPlayer != null) { //销毁页面,释放,内部的播放器被释放掉,同时如果在全屏、小窗口模式下都会退出 mVideoPlayer.release(); } } @Override public void onBackPressed() { //处理返回键逻辑;如果是全屏,则退出全屏;如果是小窗口,则退出小窗口 if (mVideoPlayer == null || !mVideoPlayer.onBackPressed()) { super.onBackPressed(); } } 10.播放器问题记录说明 11.性能优化和库大小 12.视频缓存原理介绍 网络上比较好的项目:https://github.com/danikula/AndroidVideoCache 网络用的HttpURLConnection,文件缓存处理,文件最大限度策略,回调监听处理,断点续传,代理服务等。 但是存在一些问题,比如如下所示 文件的缓存超过限制后没有按照lru算法删除, 处理返回给播放器的http响应头消息,响应头消息的获取处理改为head请求(需服务器支持) 替换网络库为okHttp(因为大部分的项目都是以okHttp为网络请求库的),但是这个改动性比较大 然后看一下怎么使用,超级简单。传入视频url链接,返回一个代理链接,然后就可以呢 HttpProxyCacheServer cacheServer = ProxyVideoCacheManager.getProxy(this); String proxyUrl = cacheServer.getProxyUrl(URL_AD); mVideoPlayer.setUrl(proxyUrl); public static HttpProxyCacheServer getProxy(Context context) { return sharedProxy == null ? (sharedProxy = newProxy(context)) : sharedProxy; } private static HttpProxyCacheServer newProxy(Context context) { return new HttpProxyCacheServer.Builder(context) .maxCacheSize(512 * 1024 * 1024) // 512MB for cache //缓存路径,不设置默认在sd_card/Android/data/[app_package_name]/cache中 //.cacheDirectory() .build(); } 大概的原理 原始的方式是直接塞播放地址给播放器,它就可以直接播放。现在我们要在中间加一层本地代理,播放器播放的时候(获取数据)是通过我们的本地代理的地址来播放的,这样我们就可以很好的在中间层(本地代理层)做一些处理,比如:文件缓存,预缓存(秒开处理),监控等。 原理详细一点来说 1.采用了本地代理服务的方式,通过原始url给播放器返回一个本地代理的一个url ,代理URL类似:http://127.0.0.1:port/视频url;(port端口为系统随机分配的有效端口,真实url是为了真正的下载),然后播放器播放的时候请求到了你本地的代理上了。 2.本地代理采用ServerSocket监听127.0.0.1的有效端口,这个时候手机就是一个服务器了,客户端就是socket,也就是播放器。 3.读取客户端就是socket来读取数据(http协议请求)解析http协议。 4.根据url检查视频文件是否存在,读取文件数据给播放器,也就是往socket里写入数据(socket通信)。同时如果没有下载完成会进行断点下载,当然弱网的话数据需要生产消费同步处理。 如何实现预加载 其实预加载的思路很简单,在进行一个播放视频后,再返回接下来需要预加载的视频url,启用线程去请求下载数据 开启一个线程去请求并预加载一部分的数据,可能需要预加载的数据大于>1,利用队列先进入的先进行加载,因此可以采用LinkedHashMap保存正在预加载的task。 在开始预加载的时候,判断该播放地址是否已经预加载,如果不是那么创建一个线程task,并且把它放到map集合中。然后执行预加载逻辑,也就是执行HttpURLConnection请求 提供取消对应url加载的任务,因为有可能该url不需要再进行预加载了,比如参考抖音,当用户瞬间下滑几个视频,那么很多视频就需要跳过了不需要再进行预加载 具体直接看项目代码:VideoCache缓冲模块 13.查看视频播放器日志 统一管理视频播放器封装库日志,方便后期排查问题 比如,视频内核,日志过滤则是:aaa 比如,视频player,日志过滤则是:bbb 比如,缓存模块,日志过滤则是:VideoCache 14.该库异常code说明 针对视频封装库,统一处理抛出的异常,为了方便开发者快速知道异常的来由,则可以查询约定的code码。 这个在sdk中特别常见,因此该库一定程度是借鉴腾讯播放器…… 视频框架:https://github.com/yangchong211/YCVideoPlayer
目录介绍 01.实际开发问题 02.线程池的优势 03.ThreadPoolExecutor参数 04.ThreadPoolExecutor使用 05.线程池执行流程 06.四种线程池类 07.execute和submit区别 08.线程池的使用技巧 01.实际开发问题 在我们的开发中经常会使用到多线程。例如在Android中,由于主线程的诸多限制,像网络请求等一些耗时的操作我们必须在子线程中运行。 我们往往会通过new Thread来开启一个子线程,待子线程操作完成以后通过Handler切换到主线程中运行。这么以来我们无法管理我们所创建的子线程,并且无限制的创建子线程,它们相互之间竞争,很有可能由于占用过多资源而导致死机或者OOM。所以在Java中为我们提供了线程池来管理我们所创建的线程。 02.线程池的优势 ①降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗; ②提高系统响应速度,当有任务到达时,无需等待新线程的创建便能立即执行; ③方便线程并发数的管控,线程若是无限制的创建,不仅会额外消耗大量系统资源,更是占用过多资源而阻塞系统或oom等状况,从而降低系统的稳定性。线程池能有效管控线程,统一分配、调优,提供资源使用率; ④更强大的功能,线程池提供了定时、定期以及可控线程数等功能的线程池,使用方便简单。 03.ThreadPoolExecutor 可以通过ThreadPoolExecutor来创建一个线程池。 ExecutorService service = new ThreadPoolExecutor(....); 下面我们就来看一下ThreadPoolExecutor中的一个构造方法。 public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) ThreadPoolExecutor参数含义 1.corePoolSize 线程池中的核心线程数,默认情况下,核心线程一直存活在线程池中,即便他们在线程池中处于闲置状态。除非我们将ThreadPoolExecutor的allowCoreThreadTimeOut属性设为true的时候,这时候处于闲置的核心线程在等待新任务到来时会有超时策略,这个超时时间由keepAliveTime来指定。一旦超过所设置的超时时间,闲置的核心线程就会被终止。 2.maximumPoolSize 线程池中所容纳的最大线程数,如果活动的线程达到这个数值以后,后续的新任务将会被阻塞。包含核心线程数+非核心线程数。 3.keepAliveTime 非核心线程闲置时的超时时长,对于非核心线程,闲置时间超过这个时间,非核心线程就会被回收。只有对ThreadPoolExecutor的allowCoreThreadTimeOut属性设为true的时候,这个超时时间才会对核心线程产生效果。 4.unit 用于指定keepAliveTime参数的时间单位。他是一个枚举,可以使用的单位有天(TimeUnit.DAYS),小时(TimeUnit.HOURS),分钟(TimeUnit.MINUTES),毫秒(TimeUnit.MILLISECONDS),微秒(TimeUnit.MICROSECONDS, 千分之一毫秒)和毫微秒(TimeUnit.NANOSECONDS, 千分之一微秒); 5.workQueue 线程池中保存等待执行的任务的阻塞队列。通过线程池中的execute方法提交的Runable对象都会存储在该队列中。我们可以选择下面几个阻塞队列。我们还能够通过实现BlockingQueue接口来自定义我们所需要的阻塞队列。 | 阻塞队列 | 说明 | | ------- | -------- | | ArrayBlockingQueue | 基于数组实现的有界的阻塞队列,该队列按照FIFO(先进先出)原则对队列中的元素进行排序。| | LinkedBlockingQueue | 基于链表实现的阻塞队列,该队列按照FIFO(先进先出)原则对队列中的元素进行排序。| | SynchronousQueue | 内部没有任何容量的阻塞队列。在它内部没有任何的缓存空间。对于SynchronousQueue中的数据元素只有当我们试着取走的时候才可能存在。| | PriorityBlockingQueue | 具有优先级的无限阻塞队列。| 6.threadFactory 线程工厂,为线程池提供新线程的创建。ThreadFactory是一个接口,里面只有一个newThread方法。 默认为DefaultThreadFactory类。 7.handler 是RejectedExecutionHandler对象,而RejectedExecutionHandler是一个接口,里面只有一个rejectedExecution方法。当任务队列已满并且线程池中的活动线程已经达到所限定的最大值或者是无法成功执行任务,这时候ThreadPoolExecutor会调用RejectedExecutionHandler中的rejectedExecution方法。在ThreadPoolExecutor中有四个内部类实现了RejectedExecutionHandler接口。在线程池中它默认是AbortPolicy,在无法处理新任务时抛出RejectedExecutionException异常。 下面是在ThreadPoolExecutor中提供的四个可选值。 我们也可以通过实现RejectedExecutionHandler接口来自定义我们自己的handler。如记录日志或持久化不能处理的任务。 | 可选值 | 说明 | | ----- | ------- | | CallerRunsPolicy | 只用调用者所在线程来运行任务。| | AbortPolicy | 直接抛出RejectedExecutionException异常。| | DiscardPolicy | 丢弃掉该任务,不进行处理。| | DiscardOldestPolicy | 丢弃队列里最近的一个任务,并执行当前任务。| 如下图所示 04.ThreadPoolExecutor使用 如下所示 ExecutorService service = new ThreadPoolExecutor(5, 10, 10, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); 对于ThreadPoolExecutor有多个构造方法,对于上面的构造方法中的其他参数都采用默认值。可以通过execute和submit两种方式来向线程池提交一个任务。 execute 当我们使用execute来提交任务时,由于execute方法没有返回值,所以说我们也就无法判定任务是否被线程池执行成功。 service.execute(new Runnable() { public void run() { System.out.println("execute方式"); } }); submit 当我们使用submit来提交任务时,它会返回一个future,我们就可以通过这个future来判断任务是否执行成功,还可以通过future的get方法来获取返回值。如果子线程任务没有完成,get方法会阻塞住直到任务完成,而使用get(long timeout, TimeUnit unit)方法则会阻塞一段时间后立即返回,这时候有可能任务并没有执行完。 Future<Integer> future = service.submit(new Callable<Integer>() { @Override public Integer call() throws Exception { System.out.println("submit方式"); return 2; } }); try { Integer number = future.get(); } catch (ExecutionException e) { e.printStackTrace(); } 线程池关闭 调用线程池的shutdown()或shutdownNow()方法来关闭线程池 shutdown原理:将线程池状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。 shutdownNow原理:将线程池的状态设置成STOP状态,然后中断所有任务(包括正在执行的)的线程,并返回等待执行任务的列表。 中断采用interrupt方法,所以无法响应中断的任务可能永远无法终止。 但调用上述的两个关闭之一,isShutdown()方法返回值为true,当所有任务都已关闭,表示线程池关闭完成,则isTerminated()方法返回值为true。当需要立刻中断所有的线程,不一定需要执行完任务,可直接调用shutdownNow()方法。 05.线程池执行流程 大概的流程图如下 文字描述如下 ①如果在线程池中的线程数量没有达到核心的线程数量,这时候就回启动一个核心线程来执行任务。 ②如果线程池中的线程数量已经超过核心线程数,这时候任务就会被插入到任务队列中排队等待执行。 ③由于任务队列已满,无法将任务插入到任务队列中。这个时候如果线程池中的线程数量没有达到线程池所设定的最大值,那么这时候就会立即启动一个非核心线程来执行任务。 ④如果线程池中的数量达到了所规定的最大值,那么就会拒绝执行此任务,这时候就会调用RejectedExecutionHandler中的rejectedExecution方法来通知调用者。 06.四种线程池类 Java中四种具有不同功能常见的线程池。 他们都是直接或者间接配置ThreadPoolExecutor来实现他们各自的功能。这四种线程池分别是newFixedThreadPool,newCachedThreadPool,newScheduledThreadPool和newSingleThreadExecutor。这四个线程池可以通过Executors类获取。 6.1 newFixedThreadPool 通过Executors中的newFixedThreadPool方法来创建,该线程池是一种线程数量固定的线程池。 ExecutorService service = Executors.newFixedThreadPool(4); 在这个线程池中 所容纳最大的线程数就是我们设置的核心线程数。 如果线程池的线程处于空闲状态的话,它们并不会被回收,除非是这个线程池被关闭。如果所有的线程都处于活动状态的话,新任务就会处于等待状态,直到有线程空闲出来。 由于newFixedThreadPool只有核心线程,并且这些线程都不会被回收,也就是它能够更快速的响应外界请求 。 从下面的newFixedThreadPool方法的实现可以看出,newFixedThreadPool只有核心线程,并且不存在超时机制,采用LinkedBlockingQueue,所以对于任务队列的大小也是没有限制的。 public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } 6.2 newCachedThreadPool 通过Executors中的newCachedThreadPool方法来创建。 public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); } 通过s上面的newCachedThreadPool方法在这里我们可以看出它的 核心线程数为0, 线程池的最大线程数Integer.MAX_VALUE。而Integer.MAX_VALUE是一个很大的数,也差不多可以说 这个线程池中的最大线程数可以任意大。 当线程池中的线程都处于活动状态的时候,线程池就会创建一个新的线程来处理任务。该线程池中的线程超时时长为60秒,所以当线程处于闲置状态超过60秒的时候便会被回收。 这也就意味着若是整个线程池的线程都处于闲置状态超过60秒以后,在newCachedThreadPool线程池中是不存在任何线程的,所以这时候它几乎不占用任何的系统资源。 对于newCachedThreadPool他的任务队列采用的是SynchronousQueue,上面说到在SynchronousQueue内部没有任何容量的阻塞队列。SynchronousQueue内部相当于一个空集合,我们无法将一个任务插入到SynchronousQueue中。所以说在线程池中如果现有线程无法接收任务,将会创建新的线程来执行任务。 6.3 newScheduledThreadPool 通过Executors中的newScheduledThreadPool方法来创建。 public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); } 它的核心线程数是固定的,对于非核心线程几乎可以说是没有限制的,并且当非核心线程处于限制状态的时候就会立即被回收。 创建一个可定时执行或周期执行任务的线程池: ScheduledExecutorService service = Executors.newScheduledThreadPool(4); service.schedule(new Runnable() { public void run() { System.out.println(Thread.currentThread().getName()+"延迟三秒执行"); } }, 3, TimeUnit.SECONDS); service.scheduleAtFixedRate(new Runnable() { public void run() { System.out.println(Thread.currentThread().getName()+"延迟三秒后每隔2秒执行"); } }, 3, 2, TimeUnit.SECONDS); 输出结果: >pool-1-thread-2延迟三秒后每隔2秒执行 ><br>pool-1-thread-1延迟三秒执行 ><br>pool-1-thread-1延迟三秒后每隔2秒执行 ><br>pool-1-thread-2延迟三秒后每隔2秒执行 ><br>pool-1-thread-2延迟三秒后每隔2秒执行 部分方法说明 schedule(Runnable command, long delay, TimeUnit unit):延迟一定时间后执行Runnable任务; schedule(Callable callable, long delay, TimeUnit unit):延迟一定时间后执行Callable任务; scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit):延迟一定时间后,以间隔period时间的频率周期性地执行任务; scheduleWithFixedDelay(Runnable command, long initialDelay, long delay,TimeUnit unit):与scheduleAtFixedRate()方法很类似,但是不同的是scheduleWithFixedDelay()方法的周期时间间隔是以上一个任务执行结束到下一个任务开始执行的间隔,而scheduleAtFixedRate()方法的周期时间间隔是以上一个任务开始执行到下一个任务开始执行的间隔,也就是这一些任务系列的触发时间都是可预知的。 ScheduledExecutorService功能强大,对于定时执行的任务,建议多采用该方法。 6.4 newSingleThreadExecutor 通过Executors中的newSingleThreadExecutor方法来创建,在这个线程池中只有一个核心线程,对于任务队列没有大小限制,也就意味着这一个任务处于活动状态时,其他任务都会在任务队列中排队等候依次执行。 newSingleThreadExecutor将所有的外界任务统一到一个线程中支持,所以在这个任务执行之间我们不需要处理线程同步的问题。 public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); } 07.execute和submit区别 先思考一个问题 为了保证项目中线程数量不会乱飙升,不好管理,我们会使用线程池,保证线程在我们的管理之下。 我们也经常说:使用线程池复用线程。那么问题是:线程池中的线程是如何复用的?是执行完成后销毁,再新建几个放那;还是始终是那几个线程(针对 coreSize 线程)。 execute和submit 调用线程池的execute方法(ExecutorService的submit方法最终也是调用execute)传进去的Runnable,并不会直接以new Thread(runnable).start()的方式来执行,而是通过一个正在运行的线程来调用我们传进去的Runnable的run方法的。 那么,这个正在运行的线程,在执行完传进去的Runnable的run方法后会销毁吗?看情况。 大部分场景下,我们都是通过Executors的newXXX方法来创建线程池的,就拿newCachedThreadPool来说: public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); } 看第三个参数(keepAliveTime):60L,后面的单位是秒,也就是说,newCachedThreadPool方法返回的线程池,它的工作线程(也就是用来调用Runnable的run方法的线程)的空闲等待时长为60秒,如果超过了60秒没有获取到新的任务,那么这个工作线程就会结束。如果在60秒内接到了新的任务,那么它会在新任务结束后重新等待。 还有另一种常用的线程池,通过newFixedThreadPool方法创建的: public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } 它跟上面的newCachedThreadPool方法一样,创建的都是ThreadPoolExecutor的对象,只是参数不同而已。可以看到第三个参数设置成了0,这就说明,如果当前工作线程数 > corePoolSize时,并且有工作线程在执行完上一个任务后没拿到新的任务,那么这个工作线程就会立即结束。 再看第二个参数(maximumPoolSize),它设置成了跟corePoolSize一样大,也就是说当前工作线程数 永远不会大于 corePoolSize了,这样的话,即使有工作线程是空闲的,也不会主动结束,会一直等待下一个任务的到来。 ThreadPoolExecutor分析 来探究一下ThreadPoolExecutor是如何管理线程的,先来看精简后的execute方法: 逻辑很清晰:当execute方法被调用时,如果当前工作线程 < corePoolSize(上面ThreadPoolExecutor构造方法的第一个参数)的话,就会创建新的线程,否则加入队列。加入队列后如果没有工作线程在运行,也会创建一个。 private final BlockingQueue<Runnable> workQueue; public void execute(Runnable command) { int c = ctl.get(); //当前工作线程还没满 if (workerCountOf(c) < corePoolSize) { //可以创建新的工作线程来执行这个任务 if (addWorker(command, true)){ //添加成功直接返回 return; } } //如果工作线程满了的话,会加入到阻塞队列中 if (workQueue.offer(command)) { int recheck = ctl.get(); //加入到队列之后,如果当前没有工作线程,那么就会创建一个工作线程 if (workerCountOf(recheck) == 0) addWorker(null, false); } } 接着看它是怎么创建新线程的: 主要操作是再次检查,然后创建Worker对象,并且把worker对象店家到HashSet集合中,最后启动工作线程。 private final HashSet<Worker> workers = new HashSet<>(); private boolean addWorker(Runnable firstTask, boolean core) { //再次检查 int wc = workerCountOf(c); if (wc >= CAPACITY || wc >= corePoolSize) return false; boolean workerStarted = false; Worker w = null; //创建Worker对象 w = new Worker(firstTask); //添加到集合中 workers.add(w); final Thread t = w.thread; //启动工作线程 t.start(); workerStarted = true; return workerStarted; } 看看Worker里面是怎么样的: 可以看到,这个Worker也是一个Runnable。构造方法里面还创建了一个Thread,这个Thread对象,对应了上面addWorker方法启动的那个thread。 private final class Worker extends AbstractQueuedSynchronizer implements Runnable { final Thread thread; Runnable firstTask; Worker(Runnable firstTask) { this.firstTask = firstTask; this.thread = getThreadFactory().newThread(this); } public void run() { runWorker(this); } } 再看Worker类中的run方法,它调用了runWorker,并把自己传了进去: Worker里面的firstTask,就是我们通过execute方法传进去的Runnable,可以看到它会在这个方法里面被执行。 执行完成之后,接着就会通过getTask方法尝试从等待队列中(上面的workQueue)获取下一个任务,如果getTask方法返回null的话,那么这个工作线程就会结束。 final void runWorker(Worker w) { Runnable task = w.firstTask; w.firstTask = null; while (task != null || (task = getTask()) != null) { try { task.run(); } finally { task = null; w.completedTasks++; } } } 最后看看runWorker方法中的getTask方法 private Runnable getTask() { boolean timedOut = false; // Did the last poll() time out? for (; ; ) { int c = ctl.get(); int wc = workerCountOf(c); //如果当前工作线程数大于指定的corePoolSize的话,就要视情况结束工作线程 boolean timed = wc > corePoolSize; //(当前工作线程数 > 指定的最大线程数 || (工作线程数 > 指定的核心线程数 && 上一次被标记超时了)) && (当前工作线程数有2个以上 || 等待队列现在是空的) if ((wc > maximumPoolSize || (timed && timedOut)) && (wc > 1 || workQueue.isEmpty())) { return null; } //如果当前工作线程数大于指定的corePoolSize,就看能不能在keepAliveTime时间内获取到新任务 //如果线程数没有 > corePoolSize的话,就会一直等待 Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); if (r != null) return r; //没能在keepAliveTime时间内获取到新任务,标记已超时 timedOut = true; } } 08.线程池的使用技巧 需要针对具体情况而具体处理,不同的任务类别应采用不同规模的线程池,任务类别可划分为CPU密集型任务、IO密集型任务和混合型任务。(N代表CPU个数) | 任务类别 | 说明 | | ------ | ----------- | | CPU密集型任务 | 线程池中线程个数应尽量少,如配置N+1个线程的线程池。| | IO密集型任务 | 由于IO操作速度远低于CPU速度,那么在运行这类任务时,CPU绝大多数时间处于空闲状态,那么线程池可以配置尽量多些的线程,以提高CPU利用率,如2*N。| | 混合型任务 | 可以拆分为CPU密集型任务和IO密集型任务,当这两类任务执行时间相差无几时,通过拆分再执行的吞吐率高于串行执行的吞吐率,但若这两类任务执行时间有数据级的差距,那么没有拆分的意义。 | Android线程池实践库:https://github.com/yangchong211/YCThreadPool
目录介绍 01.为何会有Https 02.解决方案分析 03.SSL是什么 04.RSA验证的隐患 05.CA证书身份验证 06.Https工作原理 07.Https代理作用 08.Https真安全吗 09.Https性能优化 01.为何会有Https Http的缺点 通信使用明文; 通信使用明文意味着安全性大大降低,当通信过程被窃听后,无需花费额外的投入就可看到传输的数据。 例如使用抓包工具,无需任何配置就可查看任何使用HTTP协议的通信数据; 不验证通信方身份 不验证通信方的身份,将导致通信过程被窃听后,可能会遭遇伪装,例如使用抓包工具抓取数据后,就可按照数据包的格式构造HTTP请求;任何人都坑你发送请求,不管对方是谁都返回相应。 无法验证报文的完整性 不验证报文的完整性,数据在传输过程中就可能被篡改,本来想看杨充呢,结果数据在传输过程中被换成了逗比。 遭到篡改,即没有办法确认发出的请求/相应前后一致。 Http的缺点解决方案 通信使用明文 既然明文不安全,那可以考虑使用密文,即:对通信数据进行加密。即便数据被窃听,对方依然需要花费一定的投入来破解,这种高昂的成本间接提高安全级别。 不验证通信方身份 和服务端使用相同的算法,根据网络请求参数生成一个token,请求/应答时根据token来确定双方的身份。 无法验证报文的完整性 使用MD5/SHA1等算法进行完整性验证,对方接收到数据后,根据同样的算法生成散列值,比对发送方生成的散列值,即可验证数据的完整性。 你知道Http存在哪些风险吗? 窃听风险:Http采用明文传输数据,第三方可以获知通信内容 篡改风险:第三方可以修改通信内容 冒充风险:第三方可以冒充他人身份进行通信 如何解决这些风险 SSL/TLS协议就是为了解决这些风险而设计,希望达到:所有信息加密传输,三方窃听通信内容;具有校验机制,内容一旦被篡改,通信双发立刻会发现;配备身份证书,防止身份被冒充 SSL原理及运行过程 SSL/TLS协议基本思路是采用公钥加密法(最有名的是RSA加密算法)。大概流程是,客户端向服务器索要公钥,然后用公钥加密信息,服务器收到密文,用自己的私钥解密。 为了防止公钥被篡改,把公钥放在数字证书中,证书可信则公钥可信。公钥加密计算量很大,为了提高效率,服务端和客户端都生成对话秘钥,用它加密信息,而对话秘钥是对称加密,速度非常快。而公钥用来机密对话秘钥。 02.解决方案分析 Https加密方式 Https=Http+Ssl Https保证了我们数据传输的安全,Https=Http+Ssl 之所以能保证安全主要的原理就是利用了非对称加密算法,平常用的对称加密算法之所以不安全,是因为双方是用统一的密匙进行加密解密的,只要双方任意一方泄漏了密匙,那么其他人就可以利用密匙解密数据。 非对称加密算法之所以能实现安全传输的核心精华就是:公钥加密的信息只能用私钥解开,私钥加密的信息只能被公钥解开。 非对称加密算法为什么安全 服务端申请CA机构颁发的证书,则获取到了证书的公钥和私钥,私钥只有服务器端自己知道,而公钥可以告知其他人,如可以把公钥传给客户端,这样客户端通过服务端传来的公钥来加密自己传输的数据,而服务端利用私钥就可以解密这个数据了。由于客户端这个用公钥加密的数据只有私钥能解密,而这个私钥只有服务端有,所以数据传输就安全了。 上面只是简单说了一下非对称加密算法是如何保证数据安全的,实际上Https的工作过程远比这要复杂。 03.SSL是什么 什么是SSL证书 Https协议中需要使用到SSL证书。SSL证书是一个二进制文件,里面包含经过认证的网站公钥和一些元数据,需要从经销商购买。 证书有很多类型,按认证级别分类: 域名认证(DV=Domain Validation):最低级别的认证,可以确认申请人拥有这个域名 公司认证(OV=Organization Validation):确认域名所有人是哪家公司,证书里面包含公司的信息 扩展认证(EV=Extended Validation):最高级别认证,浏览器地址栏会显示公司名称。 按覆盖范围分类: 单域名证书:只能用于单域名,foo.com证书不能用不www.foo.com 通配符证书:可用于某个域名及所有一级子域名,比如*.foo.com的证书可用于foo.com,也可用于www.foo.com 多域名证书:可用于多个域名,比如foo.com和bar.com TLS/SSL的原理是什么? SSL(Secure Sokcet Layer,安全套接字层) TLS(Transport Layer Security,传输层安全协议) 04.RSA验证的隐患 SSL/TLS协议基本思路是采用公钥加密法(最有名的是RSA加密算法),虽然说是采用非对称加密,但还是有风险隐患。 身份验证和密钥协商是TLS的基础功能,要求的前提是合法的服务器掌握着对应的私钥。但RSA算法无法确保服务器身份的合法性,因为公钥并不包含服务器的信息,存在安全隐患: 客户端C和服务器S进行通信,中间节点M截获了二者的通信; 节点M自己计算产生一对公钥pub_M和私钥pri_M; C向S请求公钥时,M把自己的公钥pub_M发给了C; C使用公钥 pub_M加密的数据能够被M解密,因为M掌握对应的私钥pri_M,而 C无法根据公钥信息判断服务器的身份,从而 C和 * M之间建立了"可信"加密连接; 中间节点 M和服务器S之间再建立合法的连接,因此 C和 S之间通信被M完全掌握,M可以进行信息的窃听、篡改等操作。 另外,服务器也可以对自己的发出的信息进行否认,不承认相关信息是自己发出。 因此该方案下至少存在两类问题: 中间人攻击和信息抵赖 05.CA证书身份验证 CA 的初始是为了解决上面非对称加密被劫持的情况,服务器申请CA证书时将服务器的“公钥”提供给CA,CA使用自己的“私钥”将“服务器的公钥”加密后(即:CA证书)返回给服务器,服务器再将“CA证书”提供给客户端。一般系统或者浏览器会内置 CA 的根证书(公钥),HTTPS 中 CA 证书的获取流程如下所示: 注意:上图步骤 2 之后,客户端获取到“CA 证书”会进行本地验证,即使用本地系统或者浏览器中的公钥进行解密,每个“CA 证书”都会有一个证书编号可用于解密后进行比对(具体验证算法请查阅相关资料)。步骤 5 之前使用的是对称加密,之后将使用对称加密来提高通讯效率。 CA证书流程原理 基本的原理为,CA负责审核信息,然后对关键信息利用私钥进行"签名",公开对应的公钥,客户端可以利用公钥验证签名。 CA也可以吊销已经签发的证书,基本的方式包括两类 CRL 文件和 OCSP。CA使用具体的流程如下: 在这个过程注意几点: a.申请证书不需要提供私钥,确保私钥永远只能服务器掌握; b.证书的合法性仍然依赖于非对称加密算法,证书主要是增加了服务器信息以及签名; c.内置 CA 对应的证书称为根证书,颁发者和使用者相同,自己为自己签名,即自签名证书(为什么说"部署自签SSL证书非常不安全") d.证书=公钥+申请者与颁发者信息+签名; CA证书链 如 CA根证书和服务器证书中间增加一级证书机构,即中间证书,证书的产生和验证原理不变,只是增加一层验证,只要最后能够被任何信任的CA根证书验证合法即可。 a.服务器证书 server.pem 的签发者为中间证书机构 inter,inter 根据证书 inter.pem 验证 server.pem 确实为自己签发的有效证书; b.中间证书 inter.pem 的签发 CA 为 root,root 根据证书 root.pem 验证 inter.pem 为自己签发的合法证书; c.客户端内置信任 CA 的 root.pem 证书,因此服务器证书 server.pem 的被信任。 06.Https工作原理 HTTPS工作原理 一、首先HTTP请求服务端生成证书,客户端对证书的有效期、合法性、域名是否与请求的域名一致、证书的公钥(RSA加密)等进行校验; 二、客户端如果校验通过后,就根据证书的公钥的有效, 生成随机数,随机数使用公钥进行加密(RSA加密); 三、消息体产生的后,对它的摘要进行MD5(或者SHA1)算法加密,此时就得到了RSA签名; 四、发送给服务端,此时只有服务端(RSA私钥)能解密。 五、解密得到的随机数,再用AES加密,作为密钥(此时的密钥只有客户端和服务端知道)。 详细一点的原理流程 客户端发起HTTPS请求 这个没什么好说的,就是用户在浏览器里输入一个https网址,然后连接到server的443端口。 服务端的配置 采用HTTPS协议的服务器必须要有一套数字证书,可以自己制作,也可以向组织申请。区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用受信任的公司申请的证书则不会弹出提示页面(startssl就是个不错的选择,有1年的免费服务)。这套证书其实就是一对公钥和私钥。如果对公钥和私钥不太理解,可以想象成一把钥匙和一个锁头,只是全世界只有你一个人有这把钥匙,你可以把锁头给别人,别人可以用这个锁把重要的东西锁起来,然后发给你,因为只有你一个人有这把钥匙,所以只有你才能看到被这把锁锁起来的东西。 传送证书 这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等等。 客户端解析证书 这部分工作是有客户端的TLS来完成的,首先会验证公钥是否有效,比如颁发机构,过期时间等等,如果发现异常,则会弹出一个警告框,提示证书存在问题。如果证书没有问题,那么就生成一个随机值。然后用证书对该随机值进行加密。就好像上面说的,把随机值用锁头锁起来,这样除非有钥匙,不然看不到被锁住的内容。 传送加密信息 这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。 服务端解密信息 服务端用私钥解密后,得到了客户端传过来的随机值(私钥),然后把内容通过该值进行对称加密。所谓对称加密就是,将信息和私钥通过某种算法混合在一起,这样除非知道私钥,不然无法获取内容,而正好客户端和服务端都知道这个私钥,所以只要加密算法够彪悍,私钥够复杂,数据就够安全。 传输加密后的信息 这部分信息是服务端用私钥加密后的信息,可以在客户端被还原。 客户端解密信息 客户端用之前生成的私钥解密服务端传过来的信息,于是获取了解密后的内容。整个过程第三方即使监听到了数据,也束手无策。 07.Https代理作用 HTTPS代理的作用是什么? 代理作用:提高访问速度、Proxy可以起到防火墙的作用、通过代理服务器访问一些不能直接访问的网站、安全性得到提高 08.Https真安全吗 charles抓包原理图 大概步骤流程 第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。 第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。 第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。 第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。 第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。 第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。 第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。 在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢? 服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。 客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。 总结一下 HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥,前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。 相对安全 从抓包的原理可以看出,对Https进行抓包,需要PC端和手机端同时安装证书。 既然这么容易被抓包,那Https会不会显得很鸡肋?其实并不会,能抓包,那是因为你信任抓包工具,手机上安装了与之对应的证书,你要不安装证书,你抓一个试试。而且安全这个课题,是在攻防中求发展,没有最安全,只有更安全,所以将攻击的成本提高了,就间接达到了安全的目标。 09.Https性能优化 HTTPS性能损耗 增加延时 分析前面的握手过程,一次完整的握手至少需要两端依次来回两次通信,至少增加延时2 RTT,利用会话缓存从而复用连接,延时也至少1 RTT* 消耗较多的CPU资源 除数据传输之外,HTTPS通信主要包括对对称加解密、非对称加解密(服务器主要采用私钥解密数据);压测 TS8 机型的单核 CPU:对称加密算法AES-CBC-256 吞吐量 600Mbps,非对称 RSA 私钥解密200次/s。不考虑其它软件层面的开销,10G 网卡为对称加密需要消耗 CPU 约17核,24核CPU最多接入 HTTPS 连接 4800;静态节点当前10G 网卡的 TS8 机型的 HTTP 单机接入能力约为10w/s,如果将所有的HTTP连接变为HTTPS连接,则明显RSA的解密最先成为瓶颈。因此,RSA的解密能力是当前困扰HTTPS接入的主要难题。 HTTPS接入优化 CDN接入 HTTPS 增加的延时主要是传输延时 RTT,RTT 的特点是节点越近延时越小,CDN 天然离用户最近,因此选择使用 CDN 作为 HTTPS 接入的入口,将能够极大减少接入延时。 CDN 节点通过和业务服务器维持长连接、会话复用和链路质量优化等可控方法,极大减少 HTTPS 带来的延时。 会话缓存 虽然前文提到 HTTPS 即使采用会话缓存也要至少1*RTT的延时,但是至少延时已经减少为原来的一半,明显的延时优化;同时,基于会话缓存建立的 HTTPS 连接不需要服务器使用RSA私钥解密获取 Pre-master 信息,可以省去CPU 的消耗。如果业务访问连接集中,缓存命中率高,则HTTPS的接入能力讲明显提升。当前TRP平台的缓存命中率高峰时期大于30%,10k/s的接入资源实际可以承载13k/的接入,收效非常可观。 硬件加速 为接入服务器安装专用的SSL硬件加速卡,作用类似 GPU,释放 CPU,能够具有更高的 HTTPS 接入能力且不影响业务程序的。测试某硬件加速卡单卡可以提供35k的解密能力,相当于175核 CPU,至少相当于7台24核的服务器,考虑到接入服务器其它程序的开销,一张硬件卡可以实现接近10台服务器的接入能力。 远程解密 本地接入消耗过多的 CPU 资源,浪费了网卡和硬盘等资源,考虑将最消耗 CPU 资源的RSA解密计算任务转移到其它服务器,如此则可以充分发挥服务器的接入能力,充分利用带宽与网卡资源。远程解密服务器可以选择 CPU 负载较低的机器充当,实现机器资源复用,也可以是专门优化的高计算性能的服务器。当前也是 CDN 用于大规模HTTPS接入的解决方案之一。 SPDY/HTTP2 前面的方法分别从减少传输延时和单机负载的方法提高 HTTPS 接入性能,但是方法都基于不改变 HTTP 协议的基础上提出的优化方法,SPDY/HTTP2 利用 TLS/SSL 带来的优势,通过修改协议的方法来提升 HTTPS 的性能,提高下载速度等。 技术博客汇总:https://github.com/yangchong211/YCBlogs
目录介绍 01.先提问一个问题 02.EventListener回调原理 03.请求开始结束监听 04.dns解析开始结束监听 05.连接开始结束监听 06.TLS连接开始结束监听 07.连接绑定和释放监听 08.request请求监听 09.response响应监听 10.如何监听统计耗时 11.应用实践之案例 01.先提问一个问题 OkHttp如何进行各个请求环节的耗时统计呢? OkHttp 版本提供了EventListener接口,可以让调用者接收一系列网络请求过程中的事件,例如DNS解析、TSL/SSL连接、Response接收等。 通过继承此接口,调用者可以监视整个应用中网络请求次数、流量大小、耗时(比如dns解析时间,请求时间,响应时间等等)情况。 02.EventListener回调原理 先来看一下 public abstract class EventListener { // 按照请求顺序回调 public void callStart(Call call) {} // 域名解析 public void dnsStart(Call call, String domainName) {} public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {} // 释放当前Transmitter的RealConnection public void connectionReleased(Call call, Connection connection) {} public void connectionAcquired(call, result){}; // 开始连接 public void connectStart(call, route.socketAddress(), proxy){} // 请求 public void requestHeadersStart(@NotNull Call call){} public void requestHeadersEnd(@NotNull Call call, @NotNull Request request) {} // 响应 public void requestBodyStart(@NotNull Call call) {} public void requestBodyEnd(@NotNull Call call, long byteCount) {} // 结束 public void callEnd(Call call) {} // 失败 public void callFailed(Call call, IOException ioe) {} } 03.请求开始结束监听 callStart(Call call) 请求开始 当一个Call(代表一个请求)被同步执行或被添加异步队列中时,即会调用这个回调方法。 需要说明这个方法是在dispatcher.executed/enqueue前执行的。 由于线程或事件流的限制,这里的请求开始并不是真正的去执行的这个请求。如果发生重定向和多域名重试时,这个方法也仅被调用一次。 final class RealCall implements Call { @Override public Response execute() throws IOException { eventListener.callStart(this); client.dispatcher().executed(this); Response result = getResponseWithInterceptorChain(); if (result == null) throw new IOException("Canceled"); return result; } @Override public void enqueue(Callback responseCallback) { eventListener.callStart(this); client.dispatcher().enqueue(new AsyncCall(responseCallback)); } } callFailed/callEnd 请求异常和请求结束 每一个callStart都对应着一个callFailed或callEnd。 callFailed在两种情况下被调用,第一种是在请求执行的过程中发生异常时。第二种是在请求结束后,关闭输入流时产生异常时。 final class RealCall implements Call { @Override public Response execute() throws IOException { try { client.dispatcher().executed(this); Response result = getResponseWithInterceptorChain(); if (result == null) throw new IOException("Canceled"); return result; } catch (IOException e) { eventListener.callFailed(this, e); throw e; } } final class AsyncCall extends NamedRunnable { @Override protected void execute() { try { Response response = getResponseWithInterceptorChain(); } catch (IOException e) { eventListener.callFailed(RealCall.this, e); } } } } //第二种 public final class StreamAllocation { public void streamFinished(boolean noNewStreams, HttpCodec codec, long bytesRead, IOException e) { ... if (e != null) { eventListener.callFailed(call, e); } else if (callEnd) { eventListener.callEnd(call); } ... } } callEnd也有两种调用场景。第一种也是在关闭流时。第二种是在释放连接时。 public final class StreamAllocation { public void streamFinished(boolean noNewStreams, HttpCodec codec, long bytesRead, IOException e) { ... if (e != null) { eventListener.callFailed(call, e); } else if (callEnd) { eventListener.callEnd(call); } ... } public void release() { ... if (releasedConnection != null) { eventListener.connectionReleased(call, releasedConnection); eventListener.callEnd(call); } } } 为什么会将关闭流和关闭连接区分开? 在http2版本中,一个连接上允许打开多个流,OkHttp使用StreamAllocation来作为流和连接的桥梁。当一个流被关闭时,要检查这条连接上还有没有其他流,如果没有其他流了,则可以将连接关闭了。 streamFinished和release作用是一样的,都是关闭当前流,并检查是否需要关闭连接。不同的是,当调用者手动取消请求时,调用的是release方法,并由调用者负责关闭请求输出流和响应输入流。 04.dns解析开始结束监听 dnsStart开始 其中的lookup(String hostname)方法代表了域名解析的过程,dnsStart/dnsEnd就是在lookup前后被调用的 DNS解析是请求DNS(Domain Name System)服务器,将域名解析成ip的过程。域名解析工作是由JDK中的InetAddress类完成的。 /** Prepares the socket addresses to attempt for the current proxy or host. */ private void resetNextInetSocketAddress(Proxy proxy) throws IOException { if (proxy.type() == Proxy.Type.SOCKS) { inetSocketAddresses.add(InetSocketAddress.createUnresolved(socketHost, socketPort)); } else { eventListener.dnsStart(call, socketHost); // Try each address for best behavior in mixed IPv4/IPv6 environments. List<InetAddress> addresses = address.dns().lookup(socketHost); if (addresses.isEmpty()) { throw new UnknownHostException(address.dns() + " returned no addresses for " + socketHost); } eventListener.dnsEnd(call, socketHost, addresses); } } 那么RouteSelector这个类是在哪里调用 public final class StreamAllocation { public StreamAllocation(ConnectionPool connectionPool, Address address, Call call, EventListener eventListener, Object callStackTrace) { this.routeSelector = new RouteSelector(address, routeDatabase(), call, eventListener); } } 05.连接开始结束监听 connectStart连接开始 OkHttp是使用Socket接口建立Tcp连接的,所以这里的连接就是指Socket建立一个连接的过程。 当连接被重用时,connectStart/connectEnd不会被调用。当请求被重定向到新的域名后,connectStart/connectEnd会被调用多次。 private void connectSocket(int connectTimeout, int readTimeout, Call call, EventListener eventListener) throws IOException { Proxy proxy = route.proxy(); Address address = route.address(); rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP ? address.socketFactory().createSocket() : new Socket(proxy); eventListener.connectStart(call, route.socketAddress(), proxy); } connectEnd连接结束 因为创建的连接有两种类型(服务端直连和隧道代理),所以callEnd有两处调用位置。为了在基于代理的连接上使用SSL,需要单独发送CONECT请求。 在连接过程中,无论是Socket连接失败,还是TSL/SSL握手失败,都会回调connectEnd。 public void connect(int connectTimeout, int readTimeout, int writeTimeout, while (true) { try { establishProtocol(connectionSpecSelector, pingIntervalMillis, call, eventListener); eventListener.connectEnd(call, route.socketAddress(), route.proxy(), protocol); break; } catch (IOException e) { eventListener.connectFailed(call, route.socketAddress(), route.proxy(), null, e); } } private void connectTunnel(int connectTimeout, int readTimeout, int writeTimeout, Call call, EventListener eventListener) throws IOException { Request tunnelRequest = createTunnelRequest(); HttpUrl url = tunnelRequest.url(); for (int i = 0; i < MAX_TUNNEL_ATTEMPTS; i++) { connectSocket(connectTimeout, readTimeout, call, eventListener); eventListener.connectEnd(call, route.socketAddress(), route.proxy(), null); } } 06.TLS连接开始结束监听 开始连接,代码如下所示 在上面看到,在Socket建立连接后,会执行一个establishProtocol方法,这个方法的作用就是TSL/SSL握手。 当存在重定向或连接重试的情况下,secureConnectStart/secureConnectEnd会被调用多次。 private void establishProtocol(ConnectionSpecSelector connectionSpecSelector, int pingIntervalMillis, Call call, EventListener eventListener) throws IOException { if (route.address().sslSocketFactory() == null) { protocol = Protocol.HTTP_1_1; socket = rawSocket; return; } eventListener.secureConnectStart(call); connectTls(connectionSpecSelector); eventListener.secureConnectEnd(call, handshake); } 结合连接监听可知 如果我们使用了HTTPS安全连接,在TCP连接成功后需要进行TLS安全协议通信,等TLS通讯结束后才能算是整个连接过程的结束,也就是说connectEnd在secureConnectEnd之后调用。 所以顺序是这样的 connectStart ---> secureConnectStart ---> secureConnectEnd ---> ConnectEnd 07.连接绑定和释放监听 因为OkHttp是基于连接复用的,当一次请求结束后并不会马上关闭当前连接,而是放到连接池中。 当有相同域名的请求时,会从连接池中取出对应的连接使用,减少了连接的频繁创建和销毁。 当根据一个请求从连接池取连接时,并打开输入输出流就是acquired,用完释放流就是released。 如果直接复用StreamAllocation中的连接,则不会调用connectionAcquired/connectReleased。 private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout, int pingIntervalMillis, boolean connectionRetryEnabled) throws IOException { synchronized (connectionPool) { if (result == null) { // 第一次查缓存 Attempt to get a connection from the pool. // Attempt to get a connection from the pool. Internal.instance.get(connectionPool, address, this, null); } } if (releasedConnection != null) { eventListener.connectionReleased(call, releasedConnection); } if (foundPooledConnection) { eventListener.connectionAcquired(call, result); } synchronized (connectionPool) { if (canceled) throw new IOException("Canceled"); if (newRouteSelection) { //第二次查缓存 List<Route> routes = routeSelection.getAll(); for (int i = 0, size = routes.size(); i < size; i++) { Route route = routes.get(i); Internal.instance.get(connectionPool, address, this, route); if (connection != null) { foundPooledConnection = true; result = connection; this.route = route; break; } } } if (!foundPooledConnection) { //如果缓存没有,则新建连接 route = selectedRoute; refusedStreamCount = 0; result = new RealConnection(connectionPool, selectedRoute); acquire(result, false); } } // If we found a pooled connection on the 2nd time around, we're done. if (foundPooledConnection) { eventListener.connectionAcquired(call, result); return result; } // Do TCP + TLS handshakes. This is a blocking operation. result.connect(connectTimeout, readTimeout, writeTimeout, pingIntervalMillis, connectionRetryEnabled, call, eventListener); routeDatabase().connected(result.route()); eventListener.connectionAcquired(call, result); return result; } connectionAcquired是在连接成功后被调用的。 但是在连接复用的情况下没有连接步骤,connectAcquired会在获取缓存连接后被调用。由于StreamAllocation是连接“Stream”和“Connection”的桥梁,所以在StreamAllocation中会持有一个RealConnection引用。StreamAllocation在查找可用连接的顺序为:StreamAllocation.RealConnection -> ConnectionPool -> ConnectionPool -> new RealConnection 08.request请求监听 在OkHttp中,HttpCodec负责对请求和响应按照Http协议进行编解码,包含发送请求头、发送请求体、读取响应头、读取响应体。 requestHeaders开始和结束,这个直接看CallServerInterceptor拦截器代码即可。 public final class CallServerInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { RealInterceptorChain realChain = (RealInterceptorChain) chain; HttpCodec httpCodec = realChain.httpStream(); StreamAllocation streamAllocation = realChain.streamAllocation(); RealConnection connection = (RealConnection) realChain.connection(); Request request = realChain.request(); long sentRequestMillis = System.currentTimeMillis(); realChain.eventListener().requestHeadersStart(realChain.call()); httpCodec.writeRequestHeaders(request); realChain.eventListener().requestHeadersEnd(realChain.call(), request); if (HttpMethod.permitsRequestBody(request.method()) && request.body() != null) { if (responseBuilder == null) { // Write the request body if the "Expect: 100-continue" expectation was met. realChain.eventListener().requestBodyStart(realChain.call()); long contentLength = request.body().contentLength(); CountingSink requestBodyOut = new CountingSink(httpCodec.createRequestBody(request, contentLength)); BufferedSink bufferedRequestBody = Okio.buffer(requestBodyOut); request.body().writeTo(bufferedRequestBody); bufferedRequestBody.close(); realChain.eventListener().requestBodyEnd(realChain.call(), requestBodyOut.successfulCount); } } return response; } } 09.response响应监听 responseHeadersStart和responseHeadersEnd代码如下所示 public final class CallServerInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { Response.Builder responseBuilder = null; if (HttpMethod.permitsRequestBody(request.method()) && request.body() != null) { if ("100-continue".equalsIgnoreCase(request.header("Expect"))) { httpCodec.flushRequest(); realChain.eventListener().responseHeadersStart(realChain.call()); responseBuilder = httpCodec.readResponseHeaders(true); } } httpCodec.finishRequest(); if (responseBuilder == null) { realChain.eventListener().responseHeadersStart(realChain.call()); responseBuilder = httpCodec.readResponseHeaders(false); } int code = response.code(); if (code == 100) { // server sent a 100-continue even though we did not request one. // try again to read the actual response responseBuilder = httpCodec.readResponseHeaders(false); response = responseBuilder .request(request) .handshake(streamAllocation.connection().handshake()) .sentRequestAtMillis(sentRequestMillis) .receivedResponseAtMillis(System.currentTimeMillis()) .build(); code = response.code(); } realChain.eventListener() .responseHeadersEnd(realChain.call(), response); return response; } } responseBodyStart监听 响应体的读取有些复杂,要根据不同类型的Content-Type决定如何读取响应体,例如固定长度的、基于块(chunk)数据的、未知长度的。具体看openResponseBody方法里面的代码。 同时Http1与Http2也有不同的解析方式。下面以Http1为例。 public final class Http1Codec implements HttpCodec { @Override public ResponseBody openResponseBody(Response response) throws IOException { streamAllocation.eventListener.responseBodyStart(streamAllocation.call); String contentType = response.header("Content-Type"); if (!HttpHeaders.hasBody(response)) { Source source = newFixedLengthSource(0); return new RealResponseBody(contentType, 0, Okio.buffer(source)); } if ("chunked".equalsIgnoreCase(response.header("Transfer-Encoding"))) { Source source = newChunkedSource(response.request().url()); return new RealResponseBody(contentType, -1L, Okio.buffer(source)); } long contentLength = HttpHeaders.contentLength(response); if (contentLength != -1) { Source source = newFixedLengthSource(contentLength); return new RealResponseBody(contentType, contentLength, Okio.buffer(source)); } return new RealResponseBody(contentType, -1L, Okio.buffer(newUnknownLengthSource())); } } responseBodyEnd监听 由下面代码可知,当响应结束后,会调用连接callEnd回调(如果异常则会调用callFailed回调) public final class StreamAllocation { public void streamFinished(boolean noNewStreams, HttpCodec codec, long bytesRead, IOException e) { eventListener.responseBodyEnd(call, bytesRead); if (releasedConnection != null) { eventListener.connectionReleased(call, releasedConnection); } if (e != null) { eventListener.callFailed(call, e); } else if (callEnd) { eventListener.callEnd(call); } } } 10.如何监听统计耗时 如何消耗记录时间 在OkHttp库中有一个EventListener类。该类是网络事件的侦听器。扩展这个类以监视应用程序的HTTP调用的数量、大小和持续时间。 所有启动/连接/获取事件最终将接收到匹配的结束/释放事件,要么成功(非空参数),要么失败(非空可抛出)。 比如,可以在开始链接记录时间;dns开始,结束等方法解析记录时间,可以计算dns的解析时间。 比如,可以在开始请求记录时间,记录connectStart,connectEnd等方法时间,则可以计算出connect连接时间。 代码如下所示 Eventlistener只适用于没有并发的情况,如果有多个请求并发执行我们需要使用Eventlistener. Factory来给每个请求创建一个Eventlistener。 这个mRequestId是唯一值,可以选择使用AtomicInteger自增+1的方式设置id,这个使用了cas保证多线程条件下的原子性特性。 /** * <pre> * @author yangchong * email : yangchong211@163.com * time : 2019/07/22 * desc : EventListener子类 * revise: * </pre> */ public class NetworkListener extends EventListener { private static final String TAG = "NetworkEventListener"; private static AtomicInteger mNextRequestId = new AtomicInteger(0); private String mRequestId ; public static Factory get(){ Factory factory = new Factory() { @NotNull @Override public EventListener create(@NotNull Call call) { return new NetworkListener(); } }; return factory; } @Override public void callStart(@NotNull Call call) { super.callStart(call); //mRequestId = mNextRequestId.getAndIncrement() + ""; //getAndAdd,在多线程下使用cas保证原子性 mRequestId = String.valueOf(mNextRequestId.getAndIncrement()); ToolLogUtils.i(TAG+"-------callStart---requestId-----"+mRequestId); saveEvent(NetworkTraceBean.CALL_START); saveUrl(call.request().url().toString()); } @Override public void dnsStart(@NotNull Call call, @NotNull String domainName) { super.dnsStart(call, domainName); ToolLogUtils.d(TAG, "dnsStart"); saveEvent(NetworkTraceBean.DNS_START); } @Override public void dnsEnd(@NotNull Call call, @NotNull String domainName, @NotNull List<InetAddress> inetAddressList) { super.dnsEnd(call, domainName, inetAddressList); ToolLogUtils.d(TAG, "dnsEnd"); saveEvent(NetworkTraceBean.DNS_END); } @Override public void connectStart(@NotNull Call call, @NotNull InetSocketAddress inetSocketAddress, @NotNull Proxy proxy) { super.connectStart(call, inetSocketAddress, proxy); ToolLogUtils.d(TAG, "connectStart"); saveEvent(NetworkTraceBean.CONNECT_START); } @Override public void secureConnectStart(@NotNull Call call) { super.secureConnectStart(call); ToolLogUtils.d(TAG, "secureConnectStart"); saveEvent(NetworkTraceBean.SECURE_CONNECT_START); } @Override public void secureConnectEnd(@NotNull Call call, @Nullable Handshake handshake) { super.secureConnectEnd(call, handshake); ToolLogUtils.d(TAG, "secureConnectEnd"); saveEvent(NetworkTraceBean.SECURE_CONNECT_END); } @Override public void connectEnd(@NotNull Call call, @NotNull InetSocketAddress inetSocketAddress, @NotNull Proxy proxy, @Nullable Protocol protocol) { super.connectEnd(call, inetSocketAddress, proxy, protocol); ToolLogUtils.d(TAG, "connectEnd"); saveEvent(NetworkTraceBean.CONNECT_END); } @Override public void connectFailed(@NotNull Call call, @NotNull InetSocketAddress inetSocketAddress, @NotNull Proxy proxy, @Nullable Protocol protocol, @NotNull IOException ioe) { super.connectFailed(call, inetSocketAddress, proxy, protocol, ioe); ToolLogUtils.d(TAG, "connectFailed"); } @Override public void requestHeadersStart(@NotNull Call call) { super.requestHeadersStart(call); ToolLogUtils.d(TAG, "requestHeadersStart"); saveEvent(NetworkTraceBean.REQUEST_HEADERS_START); } @Override public void requestHeadersEnd(@NotNull Call call, @NotNull Request request) { super.requestHeadersEnd(call, request); ToolLogUtils.d(TAG, "requestHeadersEnd"); saveEvent(NetworkTraceBean.REQUEST_HEADERS_END); } @Override public void requestBodyStart(@NotNull Call call) { super.requestBodyStart(call); ToolLogUtils.d(TAG, "requestBodyStart"); saveEvent(NetworkTraceBean.REQUEST_BODY_START); } @Override public void requestBodyEnd(@NotNull Call call, long byteCount) { super.requestBodyEnd(call, byteCount); ToolLogUtils.d(TAG, "requestBodyEnd"); saveEvent(NetworkTraceBean.REQUEST_BODY_END); } @Override public void responseHeadersStart(@NotNull Call call) { super.responseHeadersStart(call); ToolLogUtils.d(TAG, "responseHeadersStart"); saveEvent(NetworkTraceBean.RESPONSE_HEADERS_START); } @Override public void responseHeadersEnd(@NotNull Call call, @NotNull Response response) { super.responseHeadersEnd(call, response); ToolLogUtils.d(TAG, "responseHeadersEnd"); saveEvent(NetworkTraceBean.RESPONSE_HEADERS_END); } @Override public void responseBodyStart(@NotNull Call call) { super.responseBodyStart(call); ToolLogUtils.d(TAG, "responseBodyStart"); saveEvent(NetworkTraceBean.RESPONSE_BODY_START); } @Override public void responseBodyEnd(@NotNull Call call, long byteCount) { super.responseBodyEnd(call, byteCount); ToolLogUtils.d(TAG, "responseBodyEnd"); saveEvent(NetworkTraceBean.RESPONSE_BODY_END); } @Override public void callEnd(@NotNull Call call) { super.callEnd(call); ToolLogUtils.d(TAG, "callEnd"); saveEvent(NetworkTraceBean.CALL_END); generateTraceData(); NetWorkUtils.timeoutChecker(mRequestId); } @Override public void callFailed(@NotNull Call call, @NotNull IOException ioe) { super.callFailed(call, ioe); ToolLogUtils.d(TAG, "callFailed"); } private void generateTraceData(){ NetworkTraceBean traceModel = IDataPoolHandleImpl.getInstance().getNetworkTraceModel(mRequestId); Map<String, Long> eventsTimeMap = traceModel.getNetworkEventsMap(); Map<String, Long> traceList = traceModel.getTraceItemList(); traceList.put(NetworkTraceBean.TRACE_NAME_TOTAL,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.CALL_START, NetworkTraceBean.CALL_END)); traceList.put(NetworkTraceBean.TRACE_NAME_DNS,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.DNS_START, NetworkTraceBean.DNS_END)); traceList.put(NetworkTraceBean.TRACE_NAME_SECURE_CONNECT,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.SECURE_CONNECT_START, NetworkTraceBean.SECURE_CONNECT_END)); traceList.put(NetworkTraceBean.TRACE_NAME_CONNECT,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.CONNECT_START, NetworkTraceBean.CONNECT_END)); traceList.put(NetworkTraceBean.TRACE_NAME_REQUEST_HEADERS,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.REQUEST_HEADERS_START, NetworkTraceBean.REQUEST_HEADERS_END)); traceList.put(NetworkTraceBean.TRACE_NAME_REQUEST_BODY,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.REQUEST_BODY_START, NetworkTraceBean.REQUEST_BODY_END)); traceList.put(NetworkTraceBean.TRACE_NAME_RESPONSE_HEADERS,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.RESPONSE_HEADERS_START, NetworkTraceBean.RESPONSE_HEADERS_END)); traceList.put(NetworkTraceBean.TRACE_NAME_RESPONSE_BODY,NetWorkUtils.getEventCostTime(eventsTimeMap,NetworkTraceBean.RESPONSE_BODY_START, NetworkTraceBean.RESPONSE_BODY_END)); } private void saveEvent(String eventName){ NetworkTraceBean networkTraceModel = IDataPoolHandleImpl.getInstance().getNetworkTraceModel(mRequestId); Map<String, Long> networkEventsMap = networkTraceModel.getNetworkEventsMap(); networkEventsMap.put(eventName, SystemClock.elapsedRealtime()); } private void saveUrl(String url){ NetworkTraceBean networkTraceModel = IDataPoolHandleImpl.getInstance().getNetworkTraceModel(mRequestId); networkTraceModel.setUrl(url); } } 关于执行顺序,打印结果如下所示 2020-09-22 20:50:15.351 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: dnsStart 2020-09-22 20:50:15.373 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: dnsEnd 2020-09-22 20:50:15.374 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: connectStart 2020-09-22 20:50:15.404 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: secureConnectStart 2020-09-22 20:50:15.490 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: secureConnectEnd 2020-09-22 20:50:15.490 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: connectEnd 2020-09-22 20:50:15.492 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: requestHeadersStart 2020-09-22 20:50:15.492 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: requestHeadersEnd 2020-09-22 20:50:15.528 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: responseHeadersStart 2020-09-22 20:50:15.528 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: responseHeadersEnd 2020-09-22 20:50:15.532 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: responseBodyStart 2020-09-22 20:50:15.534 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: responseBodyEnd 2020-09-22 20:50:15.547 28144-28277/cn.com.zwwl.bayuwen D/NetworkEventListener: callEnd 11.应用实践之案例 网络拦截分析,主要是分析网络流量损耗,以及request,respond过程时间。打造网络分析工具…… 项目代码地址:https://github.com/yangchong211/YCAndroidTool 如果你觉得这个拦截网络助手方便了测试,以及开发中查看网络数据,可以star一下…… 网络拦截库:https://github.com/yangchong211/YCAndroidTool
目录介绍 01.下载安装 02.抓包代理设置 03.抓包Https操作 04.抓包原理介绍 05.抓包数据介绍 06.常见问题总结 07.Android拦截抓包 01.下载安装 下载地址(下载对应的平台软件即可) https://www.charlesproxy.com/download/ 下载破解文件 https://assets.examplecode.cn/file/charles.jar 打开Finder,在应用程序中选择Charles并右键选择显示包内容 显示包内容后在Content/Java目录下将破解文件复制过来替换掉原文件即可 如果打开Charles时提示:程序已损坏,打不开。您应该将它移到废纸篓。此时需要在终端中执行以下命令即可:sudo spctl --master-disable 02.抓包代理设置 charles代理设置 可以设置抓包数据类型,包括http与socket数据。可以根据需要在proxies栏下勾选。这里简单操作进行设置,Proxy ---> Proxy Settings默认端口是8888,根据实际情况可修改。 Android手机代理设置 首先获取电脑ip地址 第一种方式:查看本机IP地址:Help ---> Local IP Addresses 第二种方式:命令行方式,输入ifconfig即可 然后打开手机设置代理 注意:手机需要和电脑使用同一个Wi-Fi网络,这是前提!!! 操作步骤:打开WiFi列表 ---> 长按连接的WiFi修改网络设置代理 --- > 设置代理信息 最后抓包如下 抓包数据如下所示 03.抓包Https操作 需要做哪些操作 1.电脑上需要安装证书 2.手机上需要安装证书 3.Android项目代码设置兼容 1.电脑上需要安装证书 第一步安装证书:help ---> SSl Proxying ---> install charles root certificate ---> 安装证书 第二步设置SSL属性:Proxy ---> SSL Proxy Settings ---> 然后add操作(设置port为443)。如下所示 然后抓包试一下,会发现Android7.0手机之前可以抓包,但是Android7.0之后是无法抓包的 报错信息:客户端SSL握手失败:处理证书时出现未知问题(certificate_unknown) 如何解决在Android7.0之后也可以抓包https信息,接着往下看。 2.手机上需要安装证书 第一步下载证书 打开浏览器,输入:chls.pro/ssl,就会自己下载到手机上,这里需要记住下载完成保存到本地的路径。 第二步安装证书 设置 ---> 更多设置 ---> 系统安全 ---> 加密与凭据 ---> 从SD卡安装,选择之前保存证书的路径。 注意,有的手机是直接点击下载的文件即可安装…… 安装操作如下图所示 3.Android项目代码设置兼容 添加安全配置文件。如下所示: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found 这个异常,解决方案如下所示: <?xml version="1.0" encoding="utf-8"?> <network-security-config> <base-config cleartextTrafficPermitted="true"> <trust-anchors> <certificates overridePins="true" src="system" /> <certificates overridePins="true" src="user" /> </trust-anchors> </base-config> </network-security-config> //清单文件配置 <application android:networkSecurityConfig="@xml/network_security_config_debug"> Android 7.0及以上为何不能轻易抓取到Https请求的明文数据? 在Android 7.0(API 24 ) ,有一个名为“Network Security Configuration”的新安全功能。这个新功能的目标是允许开发人员在不修改应用程序代码的情况下自定义他们的网络安全设置。如果应用程序运行的系统版本高于或等于24,并且targetSdkVersion>=24,则只有系统(system)证书才会被信任。所以用户(user)导入的Charles根证书是不被信任的。 抓https最后结果如下所示 04.抓包原理介绍 1.抓包的原理: 代理。客户端请求->经过代理->到达服务端 服务端返回->经过代理->到达客户端 2.任何Https的 app 都能抓的到吗? 7.0以下是可以的,只要手机里安装对应CA证书,比如用charles抓包,手机要安装charles提供的证书就行。 Android 7.0 之后,Google 推出更加严格的安全机制,应用默认不信任用户证书(手机里自己安装证书),自己的app可以通过配置解决,但是抓其它app的https请求就行不通。 3.如何避免抓包 1.基于抓包原理的基础上,直接使用okhtttp禁止代理,就可以了 builder.proxy(Proxy.NO_PROXY);经过测试,可以避免抓包 2.直接使用加密协议,全是字段乱码, 把域名换装IP。这样基本别人很难抓到,像混淆一样 4.charles抓包原理图 5.大概步骤流程 第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。 第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。 第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。 第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。 第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。 第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。 第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。 在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢? 服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。 客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。 6.总结一下 HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥,前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。 05.抓包数据介绍 HTTP请求包的结构 请求报文 请求报文结构格式: 请求行: <method> <request-URL> <version> 头部: <headers> 主体: <entity-body> 请求报文结构示意图: 例子: 请求了就会收到响应包(如果对面存在HTTP服务器) POST /meme.php/home/user/login HTTP/1.1 Host: 114.215.86.90 Cache-Control: no-cache Postman-Token: bd243d6b-da03-902f-0a2c-8e9377f6f6ed Content-Type: application/x-www-form-urlencoded tel=13637829200&password=123456 常见的是那些 User-Agent:产生请求的浏览器类型。 Accept:客户端可识别的响应内容类型列表; Accept-Language:客户端可接受的自然语言; Accept-Encoding:客户端可接受的编码压缩格式; Host:请求的主机名,允许多个域名同处一个IP 地址,即虚拟主机; Connection:连接方式(close 或 keep-alive); Cookie:存储于客户端扩展字段,向同一域名的服务端发送属于该域的cookie; HTTP响应包结构 响应报文 响应报文结构格式: 状态行: <version> <status> <reason-phrase> 响应头部: <headers> 响应主体: <entity-body> 响应报文结构示意图: 例子: HTTP/1.1 200 OK Date: Sat, 02 Jan 2016 13:20:55 GMT Server: Apache/2.4.6 (CentOS) PHP/5.6.14 X-Powered-By: PHP/5.6.14 Content-Length: 78 Keep-Alive: timeout=5, max=100 Connection: Keep-Alive Content-Type: application/json; charset=utf-8 {"status":202,"info":"\u6b64\u7528\u6237\u4e0d\u5b58\u5728\uff01","data":null} 常见的响应头部参数 Allow 服务器支持哪些请求方法(如GET、POST等)。 Content-Encoding 文档的编码(Encode)方法。 Content-Length 表示内容长度。只有当浏览器使用持久HTTP连接时才需要这个数据。 Content-Type 表示后面的文档属于什么MIME类型。 Server 服务器名字。 Set-Cookie 设置和页面关联的Cookie。 ETag:被请求变量的实体值。ETag是一个可以与Web资源关联的记号(MD5值)。 Cache-Control:这个字段用于指定所有缓存机制在整个请求/响应链中必须服从的指令。 响应报文状态码 包含了状态码以及原因短语,用来告知客户端请求的结果。 关于状态码,可以看这篇文章,http状态码。 | 状态码 | 类别 | 原因短语 | | :---: | :---: | :---: | | 1XX | Informational(信息性状态码) | 接收的请求正在处理 | | 2XX | Success(成功状态码) | 请求正常处理完毕 | | 3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 | | 4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 | | 5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 | 06.常见问题总结 1.配置好后无法打开APP 在我们抓取时碰到个别APP在配置代理后无法打开,这个主要是因为该APP做了防止抓取处理,比如校验https的证书是否合法等,这种解决方法可以通过反编译APP,查看源码解决,难度较大。 2.抓取到的内容为乱码 有的APP为了防止抓取,在返回的内容上做了层加密,所以从Charles上看到的内容是乱码。这种情况下也只能反编译APP,研究其加密解密算法进行解密。 07.Android拦截抓包 网络拦截分析,主要是分析网络流量损耗,以及request,respond过程时间。打造网络分析工具…… 项目代码地址:https://github.com/yangchong211/YCAndroidTool 如果你觉得这个拦截网络助手方便了测试,以及开发中查看网络数据,可以star一下…… 网络拦截库:https://github.com/yangchong211/YCAndroidTool
目录介绍 01.基础介绍 02.stetho大概流程 03.Android中应用 04.如何使用 05.案例截图如下 06.网络请求接口信息 07.如何使用ping 01.基础介绍 该工具作用 诸葛书网络拦截分析,主要是分析网络流量损耗,以及request,respond过程时间。打造网络分析工具…… 参考stetho库地址 https://github.com/facebook/stetho 功能 Stetho 是 Facebook 开源的一个 Android 调试工具。 是一个 Chrome Developer Tools 的扩展,可用来检测应用的网络、数据库、WebKit 、SharePreference等方面的功能。 开发者也可通过它的 dumpapp 工具提供强大的命令行接口来访问应用内部。 02.stetho大概流程 用语言来描述应该是这样子: 1、安装了stetho插件的app启动之后,会启动一个本地server1(LocalSocketServer),这个本地server1等待着app(client)的连接。 2、同时,这个本地server1会与另外一个本地server2(ChromeDevtoolsServer)连接着。 3、本地app一旦连接上,数据将会不停的被发送到本地server1,然后转由server2. 4、然后Chrome Developer Tools,想访问网站一样的,访问了ChromeDevtoolsServer,随之将数据友好的展示给了开发者,这么一个过程就此完结。 整个网络请求主要分为几个步骤,而整个请求的耗时可以细分到每一个步骤里面。 DNS 解析。通过 DNS 服务器,拿到对应域名的 IP 地址。在这个步骤,比较关注 DNS 解析耗时情况、运营商 LocalDNS 的劫持、DNS 调度这些问题。 创建连接。跟服务器建立连接,这里包括 TCP 三次握手、TLS 密钥协商等工作。多个 IP/ 端口该如何选择、是否要使用 HTTPS、能否可以减少甚至省下创建连接的时间。 发送 / 接收数据。在成功建立连接之后,就可以愉快地跟服务器交互,进行组装数据、发送数据、接收数据、解析数据。思考一下,如何根据网络状况将带宽利用好,怎么样快速地侦测到网络延时,在弱网络下如何调整包大小等问题。 关闭连接。连接的关闭看起来非常简单 03.Android中应用 应用代码如下所示 new OkHttpClient.Builder() .addNetworkInterceptor(new StethoInterceptor()) .build() 那么既然网络请求添加StethoInterceptor,既可以拦截网络请求和响应信息,发送给Chrome。那么能不能自己拿来用…… 可以的 StethoInterceptor大概流程 整个流程我们可以简化为:发送请求时,给Chrome发了条消息,收到请求时,再给Chrome发条消息(具体怎么发的可以看NetworkEventReporterImpl的实现) 两条消息通过EventID联系起来,它们的类型分别是OkHttpInspectorRequest 和 OkHttpInspectorResponse,两者分别继承自NetworkEventReporter.InspectorRequest和NetworkEventReporter.InspectorResponse。 我们只要也继承自这两个类,在自己的网络库发送和收到请求时,构造一个Request和Response并发送给Chrome即可。 如何拿来用 既然Android中使用到facebook的stetho库,可以拦截手机请求请求,然后去Chrome浏览器,在浏览器地址栏输入:chrome://inspect 。即可查看请求信息。 那么能不能把这个拿到的请求信息,放到集合中,然后在Android的页面中展示呢?这样方便开发和测试查看网络请求信息,以及请求流程中的消耗时间(比如dns解析时间,请求时间,响应时间,共耗时等等) 如何消耗记录时间 在OkHttp库中有一个EventListener类。该类是网络事件的侦听器。扩展这个类以监视应用程序的HTTP调用的数量、大小和持续时间。 所有启动/连接/获取事件最终将接收到匹配的结束/释放事件,要么成功(非空参数),要么失败(非空可抛出)。 比如,可以在开始链接记录时间;dns开始,结束等方法解析记录时间,可以计算dns的解析时间。 比如,可以在开始请求记录时间,记录connectStart,connectEnd等方法时间,则可以计算出connect连接时间。 04.如何使用 如下所示 new OkHttpClient.Builder() //配置工厂监听器。主要是计算网络过程消耗时间 .eventListenerFactory(NetworkListener.get()) //主要是处理拦截请求,响应等信息 .addNetworkInterceptor(new StethoInterceptor()) .build() 该库目的 做成悬浮全局按钮,点击按钮可以查看该activity页面请求接口,可以查看请求几个接口,以及接口请求到响应消耗流量 方便查看网络请求流程,比如dns解析时间,请求时间,响应时间 方便测试查看请求数据,方便抓包。可以复制request,respond,body等内容。也可以截图 待完善功能 添加ping功能,通过ping检测网络问题,帮助诊断 需要弄一个悬浮按钮,即添加跳转网路拦截list入口 网络请求响应超过1秒后(也可能是2秒),需要给提示,便于那种网络超时 05.案例截图如下 06.网络请求接口信息 请求接口如下所示 https://www.wanandroid.com/friend/json General Request URL: https://www.wanandroid.com/friend/json Request Method: GET Status Code: 200 OK Remote Address: 47.104.74.169:443 Referrer Policy: no-referrer-when-downgrade Response Header HTTP/1.1 200 OK Server: Apache-Coyote/1.1 Cache-Control: private Expires: Thu, 01 Jan 1970 08:00:00 CST Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Thu, 10 Sep 2020 01:05:47 GMT Request Header Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Cache-Control: no-cache Connection: keep-alive Cookie: JSESSIONID=5D6302E64E9734210FA231A6FAF5799E; Hm_lvt_90501e13a75bb5eb3d067166e8d2cad8=1598920692,1599007288,1599094016,1599629553; Hm_lpvt_90501e13a75bb5eb3d067166e8d2cad8=1599699419 Host: www.wanandroid.com Pragma: no-cache Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: none Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36 Response返回body 这里省略 看截图如下 07.如何使用ping ping的使用截图 ping是一个工具 Ping是Windows、Unix和Linux系统下的一个命令。ping也属于一个通信协议,是TCP/IP协议的一部分。 利用“ping”命令可以检查网络是否连通,可以很好地帮助我们分析和判定网络故障。 Ping发送一个ICMP(Internet Control Messages Protocol)即因特网信报控制协议,回声请求消息给目的地并报告是否收到所希望的ICMP echo (ICMP回声应答),用来检查网络是否通畅或者网络连接速度的命令。广义来说即发送一个数据包,根据返回的数据包得到丢包率及平均时间得出网络的连接状态。 ping的作用有哪些 我们可能都会遇到网站打不开,当出现不开的时候,我们也不知道是那里出了问题,不知道是不是解析出了问题还是网站的空间出了问题,这时候我们就可以通过ping来查找问题,看看网站能不能ping的通。 ping在Android的应用 为了检查网络,在android上也可以通过ping来查看是否网络通。 实现方案有哪些 通过后台线程执行ping命令的方式模拟traceroute的过程,缺点就是模拟过程较慢,timeout的出现比较频繁 通过编译开源网络检测库iputilsC代码的方式对traceroute进行了套接字发送ICMP报文模拟,可以明显提高检测速度 深入理解iputils网络工具:https://blog.csdn.net/fsdev/category_1212445.html 关于代码ping的过程信息 开启一个AsyncTask,在doInBackground方法中开始解析,这个是入口。 添加头部信息,主要包括:开始诊断 + 输出关于应用、机器、网络诊断的基本信息 + 输出本地网络环境信息 tcp三次握手操作 开始执行链接,这里有两个重要信息。一个是ip集合,另一个是InetAddress数组,遍历【长度是ip集合length】,然后执行请求 创建socketAddress,有两个参数,一个是ip,一个是端口号80,然后for循环执行socket请求 在执行socket请求的时候,如果有监听到超时SocketTimeoutException异常则记录数据,如果有异常则记录数据 当出现发生timeOut,则尝试加长连接时间,注意连续两次连接超时,停止后续测试。连续两次出现IO异常,停止后续测试 当然只要有一次完整执行成功的流程,那么则记录三次握手操作成功 诊断ping信息, 同步过程。这个主要是直接通过ping命令监测网络 创建一个NetPing对象,设置每次ping发送数据包的个数为4个 然后ping本机ip地址,ping本地网观ip地址,ping本地dns。这个ping的指令是啥?这个主要是用java中的Runtime执行指令…… 开始诊断traceRoute 先调用原生jni代码,调用jni c函数执行traceroute过程。如果发生了异常,再调用java代码执行操作…… 然后通过ping命令模拟执行traceroute的过程,比如:ping -c 1 -t 1 www.jianshu.com 如果成功获得trace:IP,则再次发送ping命令获取ping的时间 在该项目中如何使用ping 直接创建一个ping,需要传递一个网址url _netDiagnoService = new NetDiagnoService(getContext(), getContext().getPackageName() , versionName, userId, deviceId, host, this); _netDiagnoService.execute(); 如何取消ping if (_netDiagnoService!=null){ _netDiagnoService.cancel(true); _netDiagnoService = null; } 或者直接停止ping。停止线程允许,并把对象设置成null _netDiagnoService.stopNetDialogsis(); 关于监听 /** * 诊断结束,输出全部日志记录 * @param log log日志输出 */ @Override public void OnNetDiagnoFinished(String log) { setText(log); } /** * 监控网络诊断过程中的日志输出 * @param log log日志输出 */ @Override public void OnNetDiagnoUpdated(String log) { showInfo += log; setText(showInfo); } 该库地址:https://github.com/yangchong211/YCAndroidTool
目录总结 01.能否利用Looper拦截崩溃 02.思考几个问题分析 03.App启动时自动开启Looper 04.拦截主进程崩溃 前沿 上一篇整体介绍了crash崩溃库崩溃重启,崩溃记录记录,查看以及分享日志等功能。 项目地址:https://github.com/yangchong211/YCAndroidTool 欢迎star 01.能否利用Looper拦截崩溃 问题思考一下 能否基于 Handler 和 Looper 拦截全局崩溃(主线程),避免 APP 退出。 能否基于 Handler 和 Looper 实现 ANR 监控。 测试代码如下所示 public class App extends Application { @Override public void onCreate() { super.onCreate(); CrashTestDemo.test(); } } //测试代码 public class CrashTestDemo { private static long startWorkTimeMillis = 0L; public static void test(){ Looper.getMainLooper().setMessageLogging(new Printer() { @Override public void println(String it) { if (it.startsWith(">>>>> Dispatching to Handler")) { startWorkTimeMillis = System.currentTimeMillis(); } else if (it.startsWith("<<<<< Finished to Handler")) { long duration = System.currentTimeMillis() - startWorkTimeMillis; if (duration > 100) { Log.e("Application---主线程执行耗时过长","$duration 毫秒,$it"); } } } }); Handler handler = new Handler(Looper.getMainLooper()); handler.post(new Runnable() { @Override public void run() { while (true){ try { Looper.loop(); } catch (Throwable e){ if (e.getMessage()!=null && e.getMessage().startsWith("Unable to start activity")){ android.os.Process.killProcess(android.os.Process.myPid()); break; } e.printStackTrace(); Log.e("Application---Looper---",e.getMessage()); } } } }); Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread t, Throwable e) { e.printStackTrace(); Log.e("Application-----","uncaughtException---异步线程崩溃,自行上报崩溃信息"); } }); } } 通过上面的代码就可以就可以实现拦截UI线程的崩溃,耗时性能监控。但是也并不能够拦截所有的异常,如果在Activity的onCreate出现崩溃,导致Activity创建失败,那么就会显示黑屏。 02.思考几个问题分析 通过上面简单的代码,我们就实现崩溃和ANR的拦截和监控,但是我们可能并不知道是为何实现的,包括我们知道出现了ANR,但是我们还需要进一步分析为何处出现ANR,如何解决。 今天分析的问题有: 如何拦截全局崩溃,避免APP退出。如何实现 ANR 监控。拦截到了之后可以做什么处理,如何优化? 03.App启动时自动开启Looper 先从APP启动开始分析,APP的启动方法是在ActivityThread中,在main方法中创建了主线程的Looper,也就是当前进程创建。 在main方法的最后调用了 Looper.loop(),在这个方法中处理主线程的任务调度,一旦执行完这个方法就意味着APP被退出了。 如果我们要避免APP被退出,就必须让APP持续执行Looper.loop()。注意这句话非常重要!!! public final class ActivityThread extends ClientTransactionHandler { ... public static void main(String[] args) { ... Looper.prepareMainLooper(); ... Looper.loop(); throw new RuntimeException("Main thread loop unexpectedly exited"); } } 那进一步分析Looper.loop()方法 在这个方法中写了一个循环,只有当 queue.next() == null 的时候才退出,看到这里我们心里可能会有一个疑问,如果没有主线程任务,是不是Looper.loop()方法就退出了呢? 实际上queue.next()其实就是一个阻塞的方法,如果没有任务或没有主动退出,会一直在阻塞,一直等待主线程任务添加进来。 当队列有任务,就会打印信息 Dispatching to ...,然后就调用 msg.target.dispatchMessage(msg);执行任务,执行完毕就会打印信息 Finished to ...,我们就可以通过打印的信息来分析 ANR,一旦执行任务超过5秒就会触发系统提示ANR,但是我们对自己的APP肯定要更加严格,我们可以给我们设定一个目标,超过指定的时长就上报统计,帮助我们进行优化。 public final class Looper { final MessageQueue mQueue; public static void loop() { final Looper me = myLooper(); if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread."); } final MessageQueue queue = me.mQueue; for (;;) { Message msg = queue.next(); // might block if (msg == null) { // No message indicates that the message queue is quitting. return; } // This must be in a local variable, in case a UI event sets the logger final Printer logging = me.mLogging; if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); } try { msg.target.dispatchMessage(msg); } finally {} if (logging != null) { logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); } msg.recycleUnchecked(); } } public void quit() { mQueue.quit(false); } } 如何让app崩溃后不会退出 如果主线程发生了异常,就会退出循环,意味着APP崩溃,所以我们我们需要进行try-catch,避免APP退出,我们可以在主线程再启动一个 Looper.loop() 去执行主线程任务,然后try-catch这个Looper.loop()方法,就不会退出。 04.拦截主进程崩溃 拦截主进程崩溃其实也有一定的弊端,因为给用户的感觉是点击没有反应,因为崩溃已经被拦截了。如果是Activity.create崩溃,会出现黑屏问题,所以如果Activity.create崩溃,必须杀死进程,让APP重启,避免出现改问题。 public class MyApplication extends Application { @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); new Handler(getMainLooper()).post(new Runnable() { @Override public void run() { while (true) { try { Looper.loop(); } catch (Throwable e) { e.printStackTrace(); // TODO 需要手动上报错误到异常管理平台,比如bugly,及时追踪问题所在。 if (e.getMessage() != null && e.getMessage().startsWith("Unable to start activity")) { // 如果打开Activity崩溃,就杀死进程,让APP重启。 android.os.Process.killProcess(android.os.Process.myPid()); break; } } } } }); } } 项目地址:https://github.com/yangchong211/YCAndroidTool
目录总结 00.异常处理几个常用api 01.UncaughtExceptionHandler 02.Java线程处理异常分析 03.Android中线程处理异常分析 04.为何使用setDefaultUncaughtExceptionHandler 前沿 上一篇整体介绍了crash崩溃库崩溃重启,崩溃记录记录,查看以及分享日志等功能。 项目地址:https://github.com/yangchong211/YCAndroidTool 欢迎star 00.异常处理几个常用api setUncaughtExceptionHandler public void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh) 设置该线程由于未捕获到异常而突然终止时调用的处理程序。 通过明确设置未捕获到的异常处理程序,线程可以完全控制它对未捕获到的异常作出响应的方式。 如果没有设置这样的处理程序,则该线程的 ThreadGroup 对象将充当其处理程序。 public Thread.UncaughtExceptionHandler getUncaughtExceptionHandler() 返回该线程由于未捕获到异常而突然终止时调用的处理程序。 如果该线程尚未明确设置未捕获到的异常处理程序,则返回该线程的 ThreadGroup 对象,除非该线程已经终止,在这种情况下,将返回 null。 setDefaultUncaughtExceptionHandler public static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh) 设置当线程由于未捕获到异常而突然终止,并且没有为该线程定义其他处理程序时所调用的默认处理程序。 未捕获到的异常处理首先由线程控制,然后由线程的 ThreadGroup 对象控制,最后由未捕获到的默认异常处理程序控制。 如果线程不设置明确的未捕获到的异常处理程序,并且该线程的线程组(包括父线程组)未特别指定其 uncaughtException 方法,则将调用默认处理程序的 uncaughtException 方法。-- 通过设置未捕获到的默认异常处理程序,应用程序可以为那些已经接受系统提供的任何“默认”行为的线程改变未捕获到的异常处理方式(如记录到某一特定设备或文件)。 public static Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler() 返回线程由于未捕获到异常而突然终止时调用的默认处理程序。 如果返回值为 null,则没有默认处理程序。 Thread.UncaughtExceptionHandler public static interface Thread.UncaughtExceptionHandler 所有已知实现类:ThreadGroup 当 Thread 因未捕获的异常而突然终止时,调用处理程序的接口。 当某一线程因未捕获的异常而即将终止时,Java 虚拟机将使用 Thread.getUncaughtExceptionHandler() 查询该线程以获得其 UncaughtExceptionHandler 的线程,并调用处理程序的 uncaughtException 方法,将线程和异常作为参数传递。 如果某一线程没有明确设置其 UncaughtExceptionHandler,则将它的 ThreadGroup 对象作为其 UncaughtExceptionHandler。 如果 ThreadGroup 对象对处理异常没有什么特殊要求,那么它可以将调用转发给默认的未捕获异常处理程序。 01.UncaughtExceptionHandler 官方介绍为: Interface for handlers invoked when a Thread abruptly terminates due to an uncaught exception. When a thread is about to terminate due to an uncaught exception the Java Virtual Machine will query the thread for its UncaughtExceptionHandler using getUncaughtExceptionHandler() and will invoke the handler's uncaughtException method, passing the thread and the exception as arguments. If a thread has not had its UncaughtExceptionHandler explicitly set, then its ThreadGroup object acts as its UncaughtExceptionHandler. If the ThreadGroup object has no special requirements for dealing with the exception, it can forward the invocation to the default uncaught exception handler. 翻译后大概的意思是 UncaughtExceptionHandler接口用于处理因为一个未捕获的异常而导致一个线程突然终止问题。 当一个线程因为一个未捕获的异常即将终止时,Java虚拟机将通过调用getUncaughtExceptionHandler() 函数去查询该线程的UncaughtExceptionHandler并调用处理器的 uncaughtException方法将线程及异常信息通过参数的形式传递进去。如果一个线程没有明确设置一个UncaughtExceptionHandler,那么ThreadGroup对象将会代替UncaughtExceptionHandler完成该行为。如果ThreadGroup没有明确指定处理该异常,ThreadGroup将转发给默认的处理未捕获的异常的处理器。 异常回调:uncaughtException uncaughtException (Thread t, Throwable e) 是一个抽象方法,当给定的线程因为发生了未捕获的异常而导致终止时将通过该方法将线程对象和异常对象传递进来。 设置默认未捕获异常处理器:setDefaultUncaughtExceptionHandler void setDefaultUncaughtExceptionHandler (Thread.UncaughtExceptionHandler eh) 设置一个处理者当一个线程突然因为一个未捕获的异常而终止时将自动被调用。 未捕获的异常处理的控制第一个被当前线程处理,如果该线程没有捕获并处理该异常,其将被线程的ThreadGroup对象处理,最后被默认的未捕获异常处理器处理。 通过设置默认的未捕获异常的处理器,对于那些早已被系统提供了默认的未捕获异常处理器的线程,一个应用可以改变处理未捕获的异常的方式,例如记录到指定的设备或者文件。 handler将会报告线程终止和不明原因异常这个情况,如果没有自定义handler, 线程管理组就被默认为报告异常的handler。 ThreadHandler 这个类就是实现了UncaughtExceptionHandler这个接口,伪代码代码如下所示 public class ThreadHandler implements Thread.UncaughtExceptionHandler { private Thread.UncaughtExceptionHandler mDefaultHandler; private boolean isInit = false; /** * CrashHandler实例 */ private static ThreadHandler INSTANCE; /** * 获取CrashHandler实例 ,单例模式 */ public static ThreadHandler getInstance() { if (INSTANCE == null) { synchronized (CrashHandler.class) { if (INSTANCE == null) { INSTANCE = new ThreadHandler(); } } } return INSTANCE; } /** * 当UncaughtException发生时会转入该函数来处理 * 该方法来实现对运行时线程进行异常处理 */ @Override public void uncaughtException(Thread t, Throwable e) { if (mDefaultHandler != null) { //收集完信息后,交给系统自己处理崩溃 //uncaughtException (Thread t, Throwable e) 是一个抽象方法 //当给定的线程因为发生了未捕获的异常而导致终止时将通过该方法将线程对象和异常对象传递进来。 mDefaultHandler.uncaughtException(t, e); } else { //否则自己处理 } } /** * 初始化,注册Context对象, * 获取系统默认的UncaughtException处理器, * 设置该CrashHandler为程序的默认处理器 * @param ctx */ public void init(Application ctx) { if (isInit){ return; } //获取系统默认的UncaughtExceptionHandler mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler(); //将当前实例设为系统默认的异常处理器 //设置一个处理者当一个线程突然因为一个未捕获的异常而终止时将自动被调用。 //未捕获的异常处理的控制第一个被当前线程处理,如果该线程没有捕获并处理该异常,其将被线程的ThreadGroup对象处理,最后被默认的未捕获异常处理器处理。 Thread.setDefaultUncaughtExceptionHandler(this); isInit = true; } } 02.Java线程处理异常分析 线程出现未捕获异常后,JVM将调用Thread中的dispatchUncaughtException方法把异常传递给线程的未捕获异常处理器。 public final void dispatchUncaughtException(Throwable e) { Thread.UncaughtExceptionHandler initialUeh = Thread.getUncaughtExceptionPreHandler(); if (initialUeh != null) { try { initialUeh.uncaughtException(this, e); } catch (RuntimeException | Error ignored) { // Throwables thrown by the initial handler are ignored } } getUncaughtExceptionHandler().uncaughtException(this, e); } public static UncaughtExceptionHandler getUncaughtExceptionPreHandler() { return uncaughtExceptionPreHandler; } Thread中存在两个UncaughtExceptionHandler。一个是静态的defaultUncaughtExceptionHandler,另一个是非静态uncaughtExceptionHandler。 // null unless explicitly set private volatile UncaughtExceptionHandler uncaughtExceptionHandler; // null unless explicitly set private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler; defaultUncaughtExceptionHandler:设置一个静态的默认的UncaughtExceptionHandler。来自所有线程中的Exception在抛出并且未捕获的情况下,都会从此路过。进程fork的时候设置的就是这个静态的defaultUncaughtExceptionHandler,管辖范围为整个进程。 uncaughtExceptionHandler:为单个线程设置一个属于线程自己的uncaughtExceptionHandler,辖范围比较小。 没有设置uncaughtExceptionHandler怎么办? 如果没有设置uncaughtExceptionHandler,将使用线程所在的线程组来处理这个未捕获异常。 线程组ThreadGroup实现了UncaughtExceptionHandler,所以可以用来处理未捕获异常。ThreadGroup类定义: private ThreadGroup group; //可以发现ThreadGroup类是集成Thread.UncaughtExceptionHandler接口的 class ThreadGroup implements Thread.UncaughtExceptionHandler{} 然后看一下ThreadGroup中实现uncaughtException(Thread t, Throwable e)方法,代码如下 默认情况下,线程组处理未捕获异常的逻辑是,首先将异常消息通知给父线程组, 然后尝试利用一个默认的defaultUncaughtExceptionHandler来处理异常, 如果没有默认的异常处理器则将错误信息输出到System.err。 也就是JVM提供给我们设置每个线程的具体的未捕获异常处理器,也提供了设置默认异常处理器的方法。 public void uncaughtException(Thread t, Throwable e) { if (parent != null) { parent.uncaughtException(t, e); } else { Thread.UncaughtExceptionHandler ueh = Thread.getDefaultUncaughtExceptionHandler(); if (ueh != null) { ueh.uncaughtException(t, e); } else if (!(e instanceof ThreadDeath)) { System.err.print("Exception in thread \"" + t.getName() + "\" "); e.printStackTrace(System.err); } } } 03.Android中线程处理异常分析 在Android平台中,应用进程fork出来后会为虚拟机设置一个未截获异常处理器, 即在程序运行时,如果有任何一个线程抛出了未被截获的异常, 那么该异常最终会抛给未截获异常处理器处理。 具体可以找到RuntimeInit类,然后在找到KillApplicationHandler类。首先看该类的入口main方法--->commonInit()--->,然后接着往下走,找到setDefaultUncaughtExceptionHandler代码如下所示 如果报告崩溃,不要再次进入——避免无限循环。如果ActivityThread分析器在此时运行,我们杀死进程,内存中的缓冲区将丢失。并且打开崩溃对话框 最后会执行finally中杀死进程的方法干掉app Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler)); private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler { private final LoggingHandler mLoggingHandler; @Override public void uncaughtException(Thread t, Throwable e) { try { if (mCrashing) return; mCrashing = true; if (ActivityThread.currentActivityThread() != null) { ActivityThread.currentActivityThread().stopProfiling(); } // Bring up crash dialog, wait for it to be dismissed ActivityManager.getService().handleApplicationCrash( mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e)); } catch (Throwable t2) { if (t2 instanceof DeadObjectException) { // System process is dead; ignore } else { try { Clog_e(TAG, "Error reporting crash", t2); } catch (Throwable t3) { // Even Clog_e() fails! Oh well. } } } finally { // Try everything to make sure this process goes away. Process.killProcess(Process.myPid()); System.exit(10); } } } UncaughtExceptionHandler存在于Thread中.当异常发生且未捕获时。异常会透过UncaughtExceptionHandler抛出。并且该线程会消亡。所以在Android中子线程死亡是允许的。主线程死亡就会导致ANR。 所以其实在fork出app进程的时候,系统已经为app设置了一个异常处理,并且最终崩溃后会直接导致执行该handler的finallly方法最后杀死app直接退出app。如果你要自己处理,你可以自己实现Thread.UncaughtExceptionHandler。 04.为何使用setDefaultUncaughtExceptionHandler Thread.UncaughtExceptionHandler 接口代码如下所示 @FunctionalInterface public interface UncaughtExceptionHandler { void uncaughtException(Thread t, Throwable e); } UncaughtExceptionHandler 未捕获异常处理接口,当一个线程由于一个未捕获异常即将崩溃时,JVM 将会通过 getUncaughtExceptionHandler() 方法获取该线程的 UncaughtExceptionHandler,并将该线程和异常作为参数传给 uncaughtException()方法。 如果没有显式设置线程的 UncaughtExceptionHandler,那么会将其 ThreadGroup 对象会作为 UncaughtExceptionHandler。 如果其 ThreadGroup 对象没有特殊的处理异常的需求,那么就会调 getDefaultUncaughtExceptionHandler() 方法获取默认的 UncaughtExceptionHandler 来处理异常。 难道要为每一个线程创建UncaughtExceptionHandler吗? 应用程序通常都会创建很多线程,如果为每一个线程都设置一次 UncaughtExceptionHandler 未免太过麻烦。 既然出现未处理异常后 JVM 最终都会调 getDefaultUncaughtExceptionHandler(),那么我们可以在应用启动时设置一个默认的未捕获异常处理器。即调用Thread.setDefaultUncaughtExceptionHandler(handler) setDefaultUncaughtExceptionHandler被调用多次如何理解? Thread.setDefaultUncaughtExceptionHandler(handler) 方法如果被多次调用的话,会以最后一次传递的 handler 为准,所以如果用了第三方的统计模块,可能会出现失灵的情况。对于这种情况,在设置默认 hander 之前,可以先通过 getDefaultUncaughtExceptionHandler() 方法获取并保留旧的 hander,然后在默认 handler 的uncaughtException 方法中调用其他 handler 的 uncaughtException 方法,保证都会收到异常信息。 项目地址:https://github.com/yangchong211/YCAndroidTool
目录总结 01.抛出异常导致崩溃分析 02.RuntimeInit类分析 03.Looper停止App就退出吗 04.handleApplicationCrash 05.native_crash如何监控 06.ANR是如何监控的 07.回过头看addErrorToDropBox 前沿 上一篇整体介绍了crash崩溃库崩溃重启,崩溃记录记录,查看以及分享日志等功能。 项目地址:https://github.com/yangchong211/YCAndroidTool 欢迎star,哈哈哈 01.抛出异常导致崩溃分析 线程中抛出异常以后的处理逻辑。 一旦线程出现抛出异常,并且我们没有捕捉的情况下,JVM将调用Thread中的dispatchUncaughtException方法把异常传递给线程的未捕获异常处理器。 如果没有设置uncaughtExceptionHandler,将使用线程所在的线程组来处理这个未捕获异常。线程组ThreadGroup实现了UncaughtExceptionHandler,所以可以用来处理未捕获异常。 public final void dispatchUncaughtException(Throwable e) { Thread.UncaughtExceptionHandler initialUeh = Thread.getUncaughtExceptionPreHandler(); if (initialUeh != null) { try { initialUeh.uncaughtException(this, e); } catch (RuntimeException | Error ignored) { // Throwables thrown by the initial handler are ignored } } getUncaughtExceptionHandler().uncaughtException(this, e); } public static UncaughtExceptionHandler getUncaughtExceptionPreHandler() { return uncaughtExceptionPreHandler; } public UncaughtExceptionHandler getUncaughtExceptionHandler() { return uncaughtExceptionHandler != null ? uncaughtExceptionHandler : group; } private ThreadGroup group; 然后看一下ThreadGroup中实现uncaughtException(Thread t, Throwable e)方法,代码如下 默认情况下,线程组处理未捕获异常的逻辑是,首先将异常消息通知给父线程组, 然后尝试利用一个默认的defaultUncaughtExceptionHandler来处理异常, 如果没有默认的异常处理器则将错误信息输出到System.err。 也就是JVM提供给我们设置每个线程的具体的未捕获异常处理器,也提供了设置默认异常处理器的方法。 public void uncaughtException(Thread t, Throwable e) { if (parent != null) { parent.uncaughtException(t, e); } else { Thread.UncaughtExceptionHandler ueh = Thread.getDefaultUncaughtExceptionHandler(); if (ueh != null) { ueh.uncaughtException(t, e); } else if (!(e instanceof ThreadDeath)) { System.err.print("Exception in thread \"" + t.getName() + "\" "); e.printStackTrace(System.err); } } } 既然Android遇到异常会发生崩溃,然后找一些哪里用到设置setDefaultUncaughtExceptionHandler,即可定位到RuntimeInit类。 02.RuntimeInit类分析 然后看一下RuntimeInit类,由于是java代码,所以首先找main方法入口。代码如下所示 public static final void main(String[] argv) { enableDdms(); if (argv.length == 2 && argv[1].equals("application")) { if (DEBUG) Slog.d(TAG, "RuntimeInit: Starting application"); redirectLogStreams(); } else { if (DEBUG) Slog.d(TAG, "RuntimeInit: Starting tool"); } commonInit(); /* * Now that we're running in interpreted code, call back into native code * to run the system. */ nativeFinishInit(); if (DEBUG) Slog.d(TAG, "Leaving RuntimeInit!"); } 然后再来看一下commonInit()方法,看看里面做了什么操作? 可以发现这里调用了setDefaultUncaughtExceptionHandler方法,设置了自定义的Handler类 protected static final void commonInit() { if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!"); /* * set handlers; these apply to all threads in the VM. Apps can replace * the default handler, but not the pre handler. */ LoggingHandler loggingHandler = new LoggingHandler(); Thread.setUncaughtExceptionPreHandler(loggingHandler); Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler)); initialized = true; } 接着看一下KillApplicationHandler类,可以发现该类实现了Thread.UncaughtExceptionHandler 接口- private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler { private final LoggingHandler mLoggingHandler; @Override public void uncaughtException(Thread t, Throwable e) { try { ensureLogging(t, e); // Don't re-enter -- avoid infinite loops if crash-reporting crashes. if (mCrashing) return; mCrashing = true; // Try to end profiling. If a profiler is running at this point, and we kill the // process (below), the in-memory buffer will be lost. So try to stop, which will // flush the buffer. (This makes method trace profiling useful to debug crashes.) if (ActivityThread.currentActivityThread() != null) { ActivityThread.currentActivityThread().stopProfiling(); } // Bring up crash dialog, wait for it to be dismissed ActivityManager.getService().handleApplicationCrash( mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e)); } catch (Throwable t2) { if (t2 instanceof DeadObjectException) { // System process is dead; ignore } else { try { Clog_e(TAG, "Error reporting crash", t2); } catch (Throwable t3) { // Even Clog_e() fails! Oh well. } } } finally { // Try everything to make sure this process goes away. Process.killProcess(Process.myPid()); System.exit(10); } } } 得出结论 其实在fork出app进程的时候,系统已经为app设置了一个异常处理,并且最终崩溃后会直接导致执行该handler的finallly方法最后杀死app直接退出app。如果你要自己处理,你可以自己实现Thread.UncaughtExceptionHandler。 03.Looper停止App就退出吗 looper如果停止了,那么app会退出吗,先做个实验看一下。代码如下所示 可以发现调用这句话,是会让app退出的。会报错崩溃日志是:java.lang.IllegalStateException: Main thread not allowed to quit. Looper.getMainLooper().quit(); //下面这种是安全退出 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { Looper.getMainLooper().quitSafely(); } 然后看一下Looper中quit方法源码 Looper的quit方法源码如下: public void quit() { mQueue.quit(false); } Looper的quitSafely方法源码如下: public void quitSafely() { mQueue.quit(true); } 以上两个方法中mQueue是MessageQueue类型的对象,二者都调用了MessageQueue中的quit方法,MessageQueue的quit方法源码如下: 可以发现上面调用了quit方法,即会出现出现崩溃,主要原因是因为调用prepare()-->new Looper(true)--->new MessageQueue(true)--->mQuitAllowed设置为true void quit(boolean safe) { if (!mQuitAllowed) { throw new IllegalStateException("Main thread not allowed to quit."); } synchronized (this) { if (mQuitting) { return; } mQuitting = true; if (safe) { removeAllFutureMessagesLocked(); } else { removeAllMessagesLocked(); } // We can assume mPtr != 0 because mQuitting was previously false. nativeWake(mPtr); } } 通过观察以上源码我们可以发现: 当我们调用Looper的quit方法时,实际上执行了MessageQueue中的removeAllMessagesLocked方法,该方法的作用是把MessageQueue消息池中所有的消息全部清空,无论是延迟消息(延迟消息是指通过sendMessageDelayed或通过postDelayed等方法发送的需要延迟执行的消息)还是非延迟消息。 当我们调用Looper的quitSafely方法时,实际上执行了MessageQueue中的removeAllFutureMessagesLocked方法,通过名字就可以看出,该方法只会清空MessageQueue消息池中所有的延迟消息,并将消息池中所有的非延迟消息派发出去让Handler去处理,quitSafely相比于quit方法安全之处在于清空消息之前会派发所有的非延迟消息。 无论是调用了quit方法还是quitSafely方法只会,Looper就不再接收新的消息。即在调用了Looper的quit或quitSafely方法之后,消息循环就终结了,这时候再通过Handler调用sendMessage或post等方法发送消息时均返回false,表示消息没有成功放入消息队列MessageQueue中,因为消息队列已经退出了。 需要注意的是Looper的quit方法从API Level 1就存在了,但是Looper的quitSafely方法从API Level 18才添加进来。 04.handleApplicationCrash 在KillApplicationHandler类中的uncaughtException方法,可以看到ActivityManager.getService().handleApplicationCrash被调用,那么这个是用来做什么的呢? ActivityManager.getService().handleApplicationCrash-->ActivityManagerService.handleApplicationCrash-->handleApplicationCrashInner方法 从下面可以看出,若传入app为null时,processName就设置为system_server public void handleApplicationCrash(IBinder app, ApplicationErrorReport.ParcelableCrashInfo crashInfo) { ProcessRecord r = findAppProcess(app, "Crash"); final String processName = app == null ? "system_server" : (r == null ? "unknown" : r.processName); handleApplicationCrashInner("crash", r, processName, crashInfo); } 然后接着看一下handleApplicationCrashInner方法做了什么 调用addErrorToDropBox将应用crash,进行封装输出。 void handleApplicationCrashInner(String eventType, ProcessRecord r, String processName, ApplicationErrorReport.CrashInfo crashInfo) { addErrorToDropBox(eventType, r, processName, null, null, null, null, null, crashInfo); mAppErrors.crashApplication(r, crashInfo); } 05.native_crash如何监控 native_crash,顾名思义,就是native层发生的crash。其实他是通过一个NativeCrashListener线程去监控的。 final class NativeCrashListener extends Thread { ... @Override public void run() { final byte[] ackSignal = new byte[1]; ... // The file system entity for this socket is created with 0777 perms, owned // by system:system. selinux restricts things so that only crash_dump can // access it. { File socketFile = new File(DEBUGGERD_SOCKET_PATH); if (socketFile.exists()) { socketFile.delete(); } } try { FileDescriptor serverFd = Os.socket(AF_UNIX, SOCK_STREAM, 0); final UnixSocketAddress sockAddr = UnixSocketAddress.createFileSystem( DEBUGGERD_SOCKET_PATH); Os.bind(serverFd, sockAddr); Os.listen(serverFd, 1); Os.chmod(DEBUGGERD_SOCKET_PATH, 0777); //1.一直循环地读peerFd文件,若发生存在,则进入consumeNativeCrashData while (true) { FileDescriptor peerFd = null; try { if (MORE_DEBUG) Slog.v(TAG, "Waiting for debuggerd connection"); peerFd = Os.accept(serverFd, null /* peerAddress */); if (MORE_DEBUG) Slog.v(TAG, "Got debuggerd socket " + peerFd); if (peerFd != null) { // the reporting thread may take responsibility for // acking the debugger; make sure we play along. //2.进入native crash数据处理流程 consumeNativeCrashData(peerFd); } } catch (Exception e) { Slog.w(TAG, "Error handling connection", e); } finally { ... } } } catch (Exception e) { Slog.e(TAG, "Unable to init native debug socket!", e); } } // Read a crash report from the connection void consumeNativeCrashData(FileDescriptor fd) { try { ... //3.启动NativeCrashReporter作为上报错误的新线程 final String reportString = new String(os.toByteArray(), "UTF-8"); (new NativeCrashReporter(pr, signal, reportString)).start(); } catch (Exception e) { ... } } } 上报native_crash的线程-->NativeCrashReporter: class NativeCrashReporter extends Thread { ProcessRecord mApp; int mSignal; String mCrashReport; NativeCrashReporter(ProcessRecord app, int signal, String report) { super("NativeCrashReport"); mApp = app; mSignal = signal; mCrashReport = report; } @Override public void run() { try { //1.包装崩溃信息 CrashInfo ci = new CrashInfo(); ci.exceptionClassName = "Native crash"; ci.exceptionMessage = Os.strsignal(mSignal); ci.throwFileName = "unknown"; ci.throwClassName = "unknown"; ci.throwMethodName = "unknown"; ci.stackTrace = mCrashReport; if (DEBUG) Slog.v(TAG, "Calling handleApplicationCrash()"); //2.转到ams中处理,跟普通crash一致,只是类型不一样 mAm.handleApplicationCrashInner("native_crash", mApp, mApp.processName, ci); if (DEBUG) Slog.v(TAG, "<-- handleApplicationCrash() returned"); } catch (Exception e) { Slog.e(TAG, "Unable to report native crash", e); } } } native crash跟到这里就结束了,后面的流程就是跟application crash一样,都会走到addErrorToDropBox中,这个最后在说。 06.ANR是如何监控的 这里就不讨论每种anr发生后的原因和具体的流程了,直接跳到已经触发ANR的位置。AppErrors.appNotResponding: final void appNotResponding(ProcessRecord app, ActivityRecord activity, ActivityRecord parent, boolean aboveSystem, final String annotation) { ArrayList<Integer> firstPids = new ArrayList<Integer>(5); SparseArray<Boolean> lastPids = new SparseArray<Boolean>(20); if (mService.mController != null) { try { //1.判断是否继续后面的流程,还是直接kill掉当前进程 // 0 == continue, -1 = kill process immediately int res = mService.mController.appEarlyNotResponding( app.processName, app.pid, annotation); if (res < 0 && app.pid != MY_PID) { app.kill("anr", true); } } catch (RemoteException e) { mService.mController = null; Watchdog.getInstance().setActivityController(null); } } //2.记录发生anr的时间 long anrTime = SystemClock.uptimeMillis(); //3.更新cpu使用情况 if (ActivityManagerService.MONITOR_CPU_USAGE) { mService.updateCpuStatsNow(); } //可以在设置中设置发生anr后,是弹框显示还是后台处理,默认是后台 // Unless configured otherwise, swallow ANRs in background processes & kill the process. boolean showBackground = Settings.Secure.getInt(mContext.getContentResolver(), Settings.Secure.ANR_SHOW_BACKGROUND, 0) != 0; boolean isSilentANR; synchronized (mService) { ... // In case we come through here for the same app before completing // this one, mark as anring now so we will bail out. app.notResponding = true; //3.将anr写入event log中 EventLog.writeEvent(EventLogTags.AM_ANR, app.userId, app.pid, app.processName, app.info.flags, annotation); // Dump thread traces as quickly as we can, starting with "interesting" processes. firstPids.add(app.pid); // Don't dump other PIDs if it's a background ANR isSilentANR = !showBackground && !isInterestingForBackgroundTraces(app); if (!isSilentANR) { int parentPid = app.pid; if (parent != null && parent.app != null && parent.app.pid > 0) { parentPid = parent.app.pid; } if (parentPid != app.pid) firstPids.add(parentPid); if (MY_PID != app.pid && MY_PID != parentPid) firstPids.add(MY_PID); for (int i = mService.mLruProcesses.size() - 1; i >= 0; i--) { ProcessRecord r = mService.mLruProcesses.get(i); if (r != null && r.thread != null) { int pid = r.pid; if (pid > 0 && pid != app.pid && pid != parentPid && pid != MY_PID) { if (r.persistent) { firstPids.add(pid); if (DEBUG_ANR) Slog.i(TAG, "Adding persistent proc: " + r); } else if (r.treatLikeActivity) { firstPids.add(pid); if (DEBUG_ANR) Slog.i(TAG, "Adding likely IME: " + r); } else { lastPids.put(pid, Boolean.TRUE); if (DEBUG_ANR) Slog.i(TAG, "Adding ANR proc: " + r); } } } } } } // 4.将主要的anr信息写到main.log中 StringBuilder info = new StringBuilder(); info.setLength(0); info.append("ANR in ").append(app.processName); if (activity != null && activity.shortComponentName != null) { info.append(" (").append(activity.shortComponentName).append(")"); } info.append("\n"); info.append("PID: ").append(app.pid).append("\n"); if (annotation != null) { info.append("Reason: ").append(annotation).append("\n"); } if (parent != null && parent != activity) { info.append("Parent: ").append(parent.shortComponentName).append("\n"); } ProcessCpuTracker processCpuTracker = new ProcessCpuTracker(true); ArrayList<Integer> nativePids = null; // don't dump native PIDs for background ANRs unless it is the process of interest String[] nativeProc = null; if (isSilentANR) { for (int i = 0; i < NATIVE_STACKS_OF_INTEREST.length; i++) { if (NATIVE_STACKS_OF_INTEREST[i].equals(app.processName)) { nativeProc = new String[] { app.processName }; break; } } int[] pid = nativeProc == null ? null : Process.getPidsForCommands(nativeProc); if(pid != null){ nativePids = new ArrayList<Integer>(pid.length); for (int i : pid) { nativePids.add(i); } } } else { nativePids = Watchdog.getInstance().getInterestingNativePids(); } //5.dump出stacktraces文件 // For background ANRs, don't pass the ProcessCpuTracker to // avoid spending 1/2 second collecting stats to rank lastPids. File tracesFile = ActivityManagerService.dumpStackTraces( true, firstPids, (isSilentANR) ? null : processCpuTracker, (isSilentANR) ? null : lastPids, nativePids); String cpuInfo = null; if (ActivityManagerService.MONITOR_CPU_USAGE) { //6.再次更新cpu使用情况 mService.updateCpuStatsNow(); synchronized (mService.mProcessCpuTracker) { //7.打印anr时cpu使用状态 cpuInfo = mService.mProcessCpuTracker.printCurrentState(anrTime); } info.append(processCpuTracker.printCurrentLoad()); info.append(cpuInfo); } info.append(processCpuTracker.printCurrentState(anrTime)); //8.当traces文件不存在时,只能打印线程日志了 if (tracesFile == null) { // There is no trace file, so dump (only) the alleged culprit's threads to the log Process.sendSignal(app.pid, Process.SIGNAL_QUIT); } ... //9.关键,回到了我们熟悉的addErrorToDropBox,进行错误信息包装跟上传了 mService.addErrorToDropBox("anr", app, app.processName, activity, parent, annotation, cpuInfo, tracesFile, null); if (mService.mController != null) { try { //10.根据appNotResponding返回结果,看是否继续等待,还是结束当前进程 // 0 == show dialog, 1 = keep waiting, -1 = kill process immediately int res = mService.mController.appNotResponding( app.processName, app.pid, info.toString()); if (res != 0) { if (res < 0 && app.pid != MY_PID) { app.kill("anr", true); } else { synchronized (mService) { mService.mServices.scheduleServiceTimeoutLocked(app); } } return; } } catch (RemoteException e) { mService.mController = null; Watchdog.getInstance().setActivityController(null); } } ... } 我们来看一下traces文件是怎么dump出来的: public static File dumpStackTraces(boolean clearTraces, ArrayList<Integer> firstPids, ProcessCpuTracker processCpuTracker, SparseArray<Boolean> lastPids, ArrayList<Integer> nativePids) { ArrayList<Integer> extraPids = null; //1.测量CPU的使用情况,以便在请求时对顶级用户进行实际的采样。 if (processCpuTracker != null) { processCpuTracker.init(); try { Thread.sleep(200); } catch (InterruptedException ignored) { } processCpuTracker.update(); // 2.爬取顶级应用到的cpu使用情况 final int N = processCpuTracker.countWorkingStats(); extraPids = new ArrayList<>(); for (int i = 0; i < N && extraPids.size() < 5; i++) { ProcessCpuTracker.Stats stats = processCpuTracker.getWorkingStats(i); if (lastPids.indexOfKey(stats.pid) >= 0) { if (DEBUG_ANR) Slog.d(TAG, "Collecting stacks for extra pid " + stats.pid); extraPids.add(stats.pid); } else if (DEBUG_ANR) { Slog.d(TAG, "Skipping next CPU consuming process, not a java proc: " + stats.pid); } } } //3.读取trace文件的保存目录 File tracesFile; final String tracesDirProp = SystemProperties.get("dalvik.vm.stack-trace-dir", ""); if (tracesDirProp.isEmpty()) { ... String globalTracesPath = SystemProperties.get("dalvik.vm.stack-trace-file", null); ... } else { ... } //4.传入指定目录,进入实际dump逻辑 dumpStackTraces(tracesFile.getAbsolutePath(), firstPids, nativePids, extraPids, useTombstonedForJavaTraces); return tracesFile; } dumpStackTraces private static void dumpStackTraces(String tracesFile, ArrayList<Integer> firstPids, ArrayList<Integer> nativePids, ArrayList<Integer> extraPids, boolean useTombstonedForJavaTraces) { ... final DumpStackFileObserver observer; if (useTombstonedForJavaTraces) { observer = null; } else { // Use a FileObserver to detect when traces finish writing. // The order of traces is considered important to maintain for legibility. observer = new DumpStackFileObserver(tracesFile); } //我们必须在20秒内完成所有堆栈转储。 long remainingTime = 20 * 1000; try { if (observer != null) { observer.startWatching(); } // 首先收集所有最重要的pid堆栈。 if (firstPids != null) { int num = firstPids.size(); for (int i = 0; i < num; i++) { if (DEBUG_ANR) Slog.d(TAG, "Collecting stacks for pid " + firstPids.get(i)); final long timeTaken; if (useTombstonedForJavaTraces) { timeTaken = dumpJavaTracesTombstoned(firstPids.get(i), tracesFile, remainingTime); } else { timeTaken = observer.dumpWithTimeout(firstPids.get(i), remainingTime); } remainingTime -= timeTaken; if (remainingTime <= 0) { Slog.e(TAG, "Aborting stack trace dump (current firstPid=" + firstPids.get(i) + "); deadline exceeded."); return; } if (DEBUG_ANR) { Slog.d(TAG, "Done with pid " + firstPids.get(i) + " in " + timeTaken + "ms"); } } } //接下来收集native pid的堆栈 if (nativePids != null) { for (int pid : nativePids) { if (DEBUG_ANR) Slog.d(TAG, "Collecting stacks for native pid " + pid); final long nativeDumpTimeoutMs = Math.min(NATIVE_DUMP_TIMEOUT_MS, remainingTime); final long start = SystemClock.elapsedRealtime(); Debug.dumpNativeBacktraceToFileTimeout( pid, tracesFile, (int) (nativeDumpTimeoutMs / 1000)); final long timeTaken = SystemClock.elapsedRealtime() - start; remainingTime -= timeTaken; if (remainingTime <= 0) { Slog.e(TAG, "Aborting stack trace dump (current native pid=" + pid + "); deadline exceeded."); return; } if (DEBUG_ANR) { Slog.d(TAG, "Done with native pid " + pid + " in " + timeTaken + "ms"); } } } // 最后,从CPU跟踪器转储所有额外PID的堆栈。 if (extraPids != null) { for (int pid : extraPids) { if (DEBUG_ANR) Slog.d(TAG, "Collecting stacks for extra pid " + pid); final long timeTaken; if (useTombstonedForJavaTraces) { timeTaken = dumpJavaTracesTombstoned(pid, tracesFile, remainingTime); } else { timeTaken = observer.dumpWithTimeout(pid, remainingTime); } remainingTime -= timeTaken; if (remainingTime <= 0) { Slog.e(TAG, "Aborting stack trace dump (current extra pid=" + pid + "); deadline exceeded."); return; } if (DEBUG_ANR) { Slog.d(TAG, "Done with extra pid " + pid + " in " + timeTaken + "ms"); } } } } finally { if (observer != null) { observer.stopWatching(); } } } 看完之后,应该可以很清楚地的明白。ANR的流程就是打印一些 ANR reason、cpu stats、线程日志,然后分别写入main.log、event.log,然后调用到addErrorToDropBox中,最后kill该进程。 07.回过头看addErrorToDropBox 为什么说addErrorToDropBox是殊途同归呢,因为无论是crash、native_crash、ANR或是wtf,最终都是来到这里,交由它去处理。那下面我们就来揭开它的神秘面纱吧。 public void addErrorToDropBox(String eventType, ProcessRecord process, String processName, ActivityRecord activity, ActivityRecord parent, String subject, final String report, final File dataFile, final ApplicationErrorReport.CrashInfo crashInfo) { // NOTE -- this must never acquire the ActivityManagerService lock, // otherwise the watchdog may be prevented from resetting the system. // Bail early if not published yet if (ServiceManager.getService(Context.DROPBOX_SERVICE) == null) return; final DropBoxManager dbox = mContext.getSystemService(DropBoxManager.class); //只有这几种类型的错误,才会进行上传 final boolean shouldReport = ("anr".equals(eventType) || "crash".equals(eventType) || "native_crash".equals(eventType) || "watchdog".equals(eventType)); // Exit early if the dropbox isn't configured to accept this report type. final String dropboxTag = processClass(process) + "_" + eventType; //1.如果DropBoxManager没有初始化,或不是要上传的类型,则直接返回 if (dbox == null || !dbox.isTagEnabled(dropboxTag)&& !shouldReport) return; ... final StringBuilder sb = new StringBuilder(1024); //2.添加一些头部log信息 appendDropBoxProcessHeaders(process, processName, sb); //3.添加崩溃进程和界面的信息 try { if (process != null) { //添加是否前台前程log sb.append("Foreground: ") .append(process.isInterestingToUserLocked() ? "Yes" : "No") .append("\n"); } //触发该崩溃的界面,可以为null if (activity != null) { sb.append("Activity: ").append(activity.shortComponentName).append("\n"); } if (parent != null && parent.app != null && parent.app.pid != process.pid) { sb.append("Parent-Process: ").append(parent.app.processName).append("\n"); } if (parent != null && parent != activity) { sb.append("Parent-Activity: ").append(parent.shortComponentName).append("\n"); } //定入简要信息 if (subject != null) { sb.append("Subject: ").append(subject).append("\n"); } sb.append("Build: ").append(Build.FINGERPRINT).append("\n"); //是否连接了调试 if (Debug.isDebuggerConnected()) { sb.append("Debugger: Connected\n"); } } catch (NullPointerException e) { e.printStackTrace(); } finally { sb.append("\n"); } final String fProcessName = processName; final String fEventType = eventType; final String packageName = getErrorReportPackageName(process, crashInfo, eventType); Slog.i(TAG,"addErrorToDropbox, real report package is "+packageName); // Do the rest in a worker thread to avoid blocking the caller on I/O // (After this point, we shouldn't access AMS internal data structures.) Thread worker = new Thread("Error dump: " + dropboxTag) { @Override public void run() { //4.添加进程的状态到dropbox中 BufferedReader bufferedReader = null; String line; try { bufferedReader = new BufferedReader(new FileReader("/proc/" + pid + "/status")); for (int i = 0; i < 5; i++) { if ((line = bufferedReader.readLine()) != null && line.contains("State")) { sb.append(line + "\n"); break; } } } catch (IOException e) { e.printStackTrace(); } finally { if (bufferedReader != null) { try { bufferedReader.close(); } catch (IOException e) { e.printStackTrace(); } } } if (report != null) { sb.append(report); } String setting = Settings.Global.ERROR_LOGCAT_PREFIX + dropboxTag; int lines = Settings.Global.getInt(mContext.getContentResolver(), setting, 0); int maxDataFileSize = DROPBOX_MAX_SIZE - sb.length() - lines * RESERVED_BYTES_PER_LOGCAT_LINE; //5.将dataFile文件定入dropbox中,一般只有anr时,会将traces文件通过该参数传递进来者,其他类型都不传. if (dataFile != null && maxDataFileSize > 0) { try { sb.append(FileUtils.readTextFile(dataFile, maxDataFileSize, "\n\n[[TRUNCATED]]")); } catch (IOException e) { Slog.e(TAG, "Error reading " + dataFile, e); } } //6.如果是crash类型,会传入crashInfo,此时将其写入dropbox中 if (crashInfo != null && crashInfo.stackTrace != null) { sb.append(crashInfo.stackTrace); } if (lines > 0) { sb.append("\n"); // 7.合并几个logcat流,取最新部分log InputStreamReader input = null; try { java.lang.Process logcat = new ProcessBuilder( "/system/bin/timeout", "-k", "15s", "10s", "/system/bin/logcat", "-v", "threadtime", "-b", "events", "-b", "system", "-b", "main", "-b", "crash", "-t", String.valueOf(lines)) .redirectErrorStream(true).start(); try { logcat.getOutputStream().close(); } catch (IOException e) {} try { logcat.getErrorStream().close(); } catch (IOException e) {} input = new InputStreamReader(logcat.getInputStream()); int num; char[] buf = new char[8192]; while ((num = input.read(buf)) > 0) sb.append(buf, 0, num); } catch (IOException e) { Slog.e(TAG, "Error running logcat", e); } finally { if (input != null) try { input.close(); } catch (IOException e) {} } } ... if (shouldReport) { synchronized (mErrorListenerLock) { try { if (mIApplicationErrorListener == null) { return; } //8.关键,在这里可以添加一个application error的接口,用来实现应用层接收崩溃信息 mIApplicationErrorListener.onError(fEventType, packageName, fProcessName, subject, dropboxTag + "-" + uuid, crashInfo); } catch (DeadObjectException e) { Slog.i(TAG, "ApplicationErrorListener.onError() E :" + e, e); mIApplicationErrorListener = null; } catch (Exception e) { Slog.i(TAG, "ApplicationErrorListener.onError() E :" + e, e); } } } } }; ... } 调用appendDropBoxProcessHeaders添加头部log信息: private void appendDropBoxProcessHeaders(ProcessRecord process, String processName, StringBuilder sb) { // Watchdog thread ends up invoking this function (with // a null ProcessRecord) to add the stack file to dropbox. // Do not acquire a lock on this (am) in such cases, as it // could cause a potential deadlock, if and when watchdog // is invoked due to unavailability of lock on am and it // would prevent watchdog from killing system_server. if (process == null) { sb.append("Process: ").append(processName).append("\n"); return; } // Note: ProcessRecord 'process' is guarded by the service // instance. (notably process.pkgList, which could otherwise change // concurrently during execution of this method) synchronized (this) { sb.append("Process: ").append(processName).append("\n"); sb.append("PID: ").append(process.pid).append("\n"); int flags = process.info.flags; IPackageManager pm = AppGlobals.getPackageManager(); //添加该进程的flag sb.append("Flags: 0x").append(Integer.toHexString(flags)).append("\n"); for (int ip=0; ip<process.pkgList.size(); ip++) { String pkg = process.pkgList.keyAt(ip); sb.append("Package: ").append(pkg); try { PackageInfo pi = pm.getPackageInfo(pkg, 0, UserHandle.getCallingUserId()); if (pi != null) { sb.append(" v").append(pi.getLongVersionCode()); if (pi.versionName != null) { sb.append(" (").append(pi.versionName).append(")"); } } } catch (RemoteException e) { Slog.e(TAG, "Error getting package info: " + pkg, e); } sb.append("\n"); } //如果是执行安装的app,会在log中添加此项 if (process.info.isInstantApp()) { sb.append("Instant-App: true\n"); } } } 项目地址:https://github.com/yangchong211/YCAndroidTool
目录介绍 01.该库具有的功能 02.该库优势分析 03.该库如何使用 04.降低非必要crash 05.异常恢复原理 06.后续的需求说明 07.异常栈轨迹原理 08.部分问题反馈 09.其他内容说明 01.该库具有的功能 1.1 功能说明 异常崩溃后思考的一些问题 1.是否需要恢复activity栈,以及所在崩溃页面数据 2.crash信息保存和异常捕获,是否和百度bug崩溃统计sdk等兼容。是否方便接入 3.是否要回到栈顶部的那个activity(保存栈信息) 4.崩溃后需要收集哪些信息。手机信息,app信息,崩溃堆栈,内存信息等 5.异常崩溃如何友好退出,以及崩溃后调用重启app是否会出现数据异常 6.针对native代码崩溃,如何记录日志写到文件中 该库可以做一些什么 1.在Android手机上显示闪退崩溃信息,并且崩溃详情信息可以保存,分享给开发 主要是测试同学在测试中发现了崩溃,然后跑过去跟开发说,由于不容易复现导致开发童鞋不承认……有时候用的bug统计不是那么准! 2.对于某些设备,比如做Kindle开发,可以设置崩溃重启app操作 3.暴露了用户上传自己捕获的crash数据,以及崩溃重启的接口监听操作 4.一个崩溃日志保存到一个文件中,文件命名规则【版本+日期+异常】:V1.0_2020-09-02_09:05:01_java.lang.NullPointerException.txt 5.崩溃日志list可以获取,支持查看日志详情,并且可以分享,截图,以及复制崩溃信息 6.收集崩溃日志包括,设备信息,进程信息,崩溃信息(Java崩溃、Native崩溃 or ANR) 7.收集崩溃时的内存信息(OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系),完善中 1.2 截图如下所示 1.3崩溃后日志记录 1.4 崩溃流程图 02.该库优势分析 低入侵性接入该lib,不会影响你的其他业务。暴露崩溃重启,以及支持开发者自己捕获crash数据的接口!能够收集崩溃中的日志写入文件,记录包括设备信息,进程信息,崩溃信息(Java崩溃、Native崩溃 or ANR),以及崩溃时内存信息到file文件中。支持用户获取崩溃列表,以及跳转崩溃日志详情页面,并且可以将崩溃日志分享,截长图,复制等操作。可以方便测试和产品给开发提出那种偶发性bug的定位日志,免得对于偶发行崩溃,开发总是不承认……开发总是不承认…… 03.该库如何使用 如何引入该库 implementation 'cn.yc:ToolLib:1.0.0' //GitHub代码 https://github.com/yangchong211/YCAndroidTool 初始化代码如下所示。建议在Application中初始化…… CrashHandler.getInstance().init(this, new CrashListener() { /** * 重启app */ @Override public void againStartApp() { CrashToolUtils.reStartApp1(App.this,1000); //CrashToolUtils.reStartApp2(App.this,1000, MainActivity.class); //CrashToolUtils.reStartApp3(AppManager.getAppManager().currentActivity()); } /** * 自定义上传crash,支持开发者上传自己捕获的crash数据 * @param ex ex */ @Override public void recordException(Throwable ex) { //自定义上传crash,支持开发者上传自己捕获的crash数据 //StatService.recordException(getApplication(), ex); } }); 关于重启App的操作有三种方式api //开启一个新的服务KillSelfService,用来重启本APP【使用handler延迟】 CrashToolUtils.reStartApp1(App.this,1000); //用来重启本APP[使用闹钟,整体重启,临时数据清空(推荐)] CrashToolUtils.reStartApp2(App.this,1000, MainActivity.class); //检索获取项目中LauncherActivity,然后设置该activity的flag和component启动app【推荐】 CrashToolUtils.reStartApp3(AppManager.getAppManager().currentActivity()); 关于获取崩溃目录api //崩溃文件存储路径:/storage/emulated/0/Android/data/你的包名/cache/crashLogs //崩溃页面截图存储路径:/storage/emulated/0/Android/data/你的包名/cache/crashPics String crashLogPath = ToolFileUtils.getCrashLogPath(this); String crashPicPath = ToolFileUtils.getCrashPicPath(this); 关于崩溃日志记录 日志记录路径:/storage/emulated/0/Android/data/你的包名/cache/crashLogs 日志文件命名:V1.0_2020-09-02_09:05:01_java.lang.NullPointerException.txt【版本+日期+异常】 关于跳转错误日志list列表页面 跳转日志列表页面如下所示,这里调用一行代码即可。点击该页面list条目即可进入详情 CrashToolUtils.startCrashListActivity(this); 那么如何获取所有崩溃日志的list呢。建议放到子线程中处理!! List<File> fileList = ToolFileUtils.getCrashFileList(this); //如果是要自己拿到这些文件,建议根据时间来排个序 //排序 Collections.sort(fileList, new Comparator<File>() { @Override public int compare(File file01, File file02) { try { //根据修改时间排序 long lastModified01 = file01.lastModified(); long lastModified02 = file02.lastModified(); if (lastModified01 > lastModified02) { return -1; } else { return 1; } } catch (Exception e) { return 1; } } }); 如何删除单个文件操作 //返回true表示删除成功 boolean isDelete = ToolFileUtils.deleteFile(file.getPath()); 如何删除所有的文件。建议放到子线程中处理!! File fileCrash = new File(ToolFileUtils.getCrashLogPath(CrashListActivity.this)); ToolFileUtils.deleteAllFiles(fileCrash); 如何获取崩溃文件中的内容 //获取内容 String crashContent = ToolFileUtils.readFile2String(filePath); 还有一些关于其他的api,如下。这个主要是方便测试同学或者产品,避免开发不承认那种偶发性崩溃bug…… //拷贝文件,两个参数分别是源文件,还有目标文件 boolean copy = ToolFileUtils.copyFile(srcFile, destFile); //分享文件。这个是调用原生的分享 CrashLibUtils.shareFile(CrashDetailsActivity.this, destFile); //截图崩溃然后保存到相册。截图---> 创建截图存储文件路径---> 保存图片【图片质量,缩放比还有采样率压缩】 final Bitmap bitmap = ScreenShotsUtils.measureSize(this,view); String crashPicPath = ToolFileUtils.getCrashPicPath(CrashDetailsActivity.this) + "/crash_pic_" + System.currentTimeMillis() + ".jpg"; boolean saveBitmap = CrashLibUtils.saveBitmap(CrashDetailsActivity.this, bitmap, crashPicPath); 05.异常恢复原理 第一种方式,开启一个新的服务KillSelfService,用来重启本APP。 CrashToolUtils.reStartApp1(App.this,1000); 第二种方式,使用闹钟延时,然后重启app CrashToolUtils.reStartApp2(App.this,1000, MainActivity.class); 第三种方式,检索获取项目中LauncherActivity,然后设置该activity的flag和component启动app CrashToolUtils.reStartApp3(AppManager.getAppManager().currentActivity()); 关于app启动方式详细介绍 App启动介绍 06.后续的需求说明 可能不兼容 该库尚未通过多进程应用程序进行测试。如果您使用这种配置进行测试,请提供反馈! 如果您的应用程序初始化监听或错误活动崩溃,则有可能进入无限重启循环(在大多数情况下,库会对此进行检查,但在极少数情况下可能会发生)。 修复Android P反射限制导致的Activity生命周期异常无法finish Activity问题。某些机型还是不兼容…… App崩溃收集信息说明 收集崩溃时的基本信息 进程(前台进程还是后台进程) 线程(是否是 UI 线程) 崩溃堆栈(具体崩溃在系统的代码,还是我们自己的代码里面) 崩溃堆栈类型(Java 崩溃、Native 崩溃 or ANR) 收集崩溃时的系统信息 机型、系统、厂商、CPU、ABI、Linux 版本等。(寻找共性) Logcat。(包括应用、系统的运行日志,其中会记录 App 运行的一些基本情况) 收集崩溃时的内存信息(OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系) 系统剩余内存。(系统可用内存很小 – 低于 MemTotal 的 10%时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现) 虚拟内存(但是很多类似OOM、tgkill 等问题都是虚拟内存不足导致的) 应用使用内存(得出应用本身内存的占用大小和分布) 线程数 收集崩溃时的应用信息 崩溃场景(崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中) 关键操作路径(记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助) 其他自定义信息(不同应用关心的重点不一样。例如运行时间、是否加载了补丁、是否是全新安装或升级等) 07.异常栈轨迹原理 Android发生异常为何崩溃 一旦线程出现抛出异常,并且我们没有捕捉的情况下,JVM将调用Thread中的dispatchUncaughtException方法把异常传递给线程的未捕获异常处理器。发现最后会使用到Thread.getDefaultUncaughtExceptionHandler() 既然Android遇到异常会发生崩溃,然后找一些哪里用到设置setDefaultUncaughtExceptionHandler,即可定位到RuntimeInit类。 具体可以找到RuntimeInit类,然后在找到KillApplicationHandler类。首先看该类的入口main方法--->commonInit()--->,然后接着往下走,找到setDefaultUncaughtExceptionHandler代码。当出现异常是try-catch,并且在finally中直接kill杀死app操作。 详细可以看:Android项目崩溃分析 崩溃后异常堆栈链是如何形成的 待完善,看:异常栈轨迹处理 08.部分问题反馈 该异常捕获实效了是什么情况? Thread.setDefaultUncaughtExceptionHandler(handler) 方法如果被多次调用的话,会以最后一次传递的 handler 为准,所以如果用了第三方的统计模块,可能会出现失灵的情况。对于这种情况,在设置默认 hander 之前,可以先通过 getDefaultUncaughtExceptionHandler() 方法获取并保留旧的 hander,然后在默认 handler 的uncaughtException 方法中调用其他 handler 的 uncaughtException 方法,保证都会收到异常信息。 关于上传日志介绍 设置该异常初始化后,在进入全局异常时系统就提示尽快收集信息,进程将被结束,因此不可以在此时做网络上传崩溃信息。可以在此时将错误日志写入到file文件或者sp中。 比如:通过SharedPreferences将错误日志的路径写入配置文件中,在启动的时候先检测该配置文件是否有错误日志信息,如果有则读取文件,然后实现日志上传。上传完成后删除该sp文件…… 使用looper可以拦截崩溃和anr吗 可以实现拦截UI线程的崩溃,耗时性能监控。但是也并不能够拦截所有的异常。如果在Activity的onCreate出现崩溃,导致Activity创建失败,那么就会显示黑屏。 fork出app进程后,在ActivityThread中,在main方法的最后调用了 Looper.loop(),在这个方法中处理主线程的任务调度,一旦执行完这个方法就意味着APP被退出了。 果主线程发生了异常,就会退出循环,意味着APP崩溃,所以我们我们需要进行try-catch,避免APP退出,再启动一个 Looper.loop() 去执行主线程任务,就不会退出。 looper拦截崩溃或者anr,存在一个巨大的问题,就是按钮点不动或者无反应。有可能导致出现其他问题……这个需要慎重使用 09.其他内容说明 混淆 -keep class com.yc.toollib.* { ; } -keepnames class com.yc.toollib.* { ; } 该库笔记介绍 崩溃原理深度探索 常驻应用崩溃后处理 异常栈轨迹处理 Loop拦截崩溃和ANR App重启几种方式 其他项目推荐 1.开源博客汇总 2.降低Crash崩溃库 3.视频播放器封装库 4.状态切换管理器封装库 5.复杂RecyclerView封装库 6.弹窗封装库 7.版本更新封装库 8.状态栏封装库 9.轻量级线程池封装库 10.轮播图封装库 11.音频播放器 12.画廊与图片缩放控件 13.Python多渠道打包 14.整体侧滑动画封装库 15.Python爬虫妹子图 17.自定义进度条 18.自定义折叠和展开布局 19.商品详情页分页加载 20.在任意View控件上设置红点控件 21.仿抖音一次滑动一个页面播放视频库 该开源库地址:https://github.com/yangchong211/YCAndroidTool
倒计时方案深入分析 目录介绍 01.使用多种方式实现倒计时 02.各种倒计时器分析 03.CountDownTimer解读 04.Timer和TimerTask解读 05.自定义倒计时器案例 01.使用多种方式实现倒计时 首先看一下需求 要求可以创建多个倒计时器,可以暂停,以及恢复暂停。可以自由设置倒计时器总时间,倒计时间隔。下面会一步步实现一个多功能倒计时器。 01.使用Handler实现倒计时 mHandler + runnable ,这种是最常见的一种方式。实质是不断调用mHandler.postDelayed(this, 1000)达到定时周期目的 02.使用CountDownTimer实现倒计时 也是利用mHandler + runnable,在此基础上简单封装一下。使用场景更强大,比如一个页面有多个倒计时器,用这个就很方便…… 03.利用Timer实现定时器 使用Timer + TimerTask + handler方式实现倒计时 04.使用chronometer控件倒计时 新出的继承TextView组件,里头是使用了View.postDelayed + runnable实现倒计时 05.利用动画实现倒计时 这种方式用的比较少,但也是一种思路。主要是设置动画时间,在onAnimationUpdate监听设置倒计时处理 具体代码案例可以看 6种实现倒计时器的代码案例 具体代码案例 6种实现倒计时器方案 02.各种倒计时器分析 第一种利用Handler实现倒计时 这种用的很普遍,但存在一个问题。如果是一个页面需要开启多个倒计时【比如列表页面】,则比较难处理。 第二种使用CountDownTimer实现倒计时 new CountDownTimer(5000, 1000).start() 期待的效果是:“5-4-3-2-1-finish”或者“5-4-3-2-1-0”。这里,显示 0 和 finish 的时间应该是一致的,所以把 0 放在 onFinish() 里显示也可以。但实际有误差…… 存在的几个问题 问题1. 每次 onTick() 都会有几毫秒的误差,并不是期待的准确的 "5000, 4000, 3000, 2000, 1000, 0"。 问题2. 多运行几次,就会发现这几毫秒的误差,导致了计算得出的剩余秒数并不准确,如果你的倒计时需要显示剩余秒数,就会发生 秒数跳跃/缺失 的情况(比如一开始从“4”开始显示——缺少“5”,或者直接从“5”跳到了“3”——缺少“4”)。 问题3. 最后一次 onTick() 到 onFinish() 的间隔通常超过了 1 秒,差不多是 2 秒左右。如果你的倒计时在显示秒数,就能很明显的感觉到最后 1 秒停顿的时间很长。 问题4. 如果onTick耗时超时,比如超过了1000毫秒,则会导致出现onTick出现跳动问题 解决方案 具体看lib中的CountDownTimer类。下面也会分析到 注意:onTick方法中如何执行耗时操作【大于1秒的执行代码】,建议使用handler消息机制进行处理,避免出现其他问题。 第三种利用Timer实现定时器 注意点 Timer和TimerTask都有cancel方法,而且最好同时调用;如果已经cancel,下次必须创建新的Timer才能schedule。 可能存在的问题 如果你在当前的activity中schedule了一个task,但是没有等到task结束,就按Back键finish了当前的activity,Timer和TimerTask并不会自动cancel或者销毁,它还会在后台运行,此时如果你在task的某个阶段要调起一个控件(比如AlertDialog),而该控制依赖被销毁的activity,那么将会引发crash。 所以建议在页面销毁的时候,将Timer和TimerTask都有cancel结束并且设置成null Timer 的方式实现定时任务,用来做倒计时是没有问题的。但是如果用来执行周期任务,恰好又有多个任务,恰好两个任务之间的时间间隔又比前一个任务执行时间短就会发生定时不准确的现象了。Timer 在执行过程中如果任务跑出了异常,Timer 会停止所有的任务。Timer 执行周期任务时依赖系统时间,系统时间的变化会引起 Timer 任务执行的变化。 03.CountDownTimer解读 03.1 来看一个问题 先看案例代码,如下所示 期待的效果是:“5-4-3-2-1-finish”或者“5-4-3-2-1-0”。这里,显示 0 和 finish 的时间应该是一致的,所以把 0 放在 onFinish() 里显示也可以。 mCountDownTimer = new CountDownTimer(5000, 1000) { @Override public void onTick(long millisUntilFinished) { Log.i(TAG, "----倒计时----onTick--"+millisUntilFinished); } public void onFinish() { Log.i(TAG, "----倒计时----onFinish"); } }; 然后看一下打印日志,如下所示 2020-08-05 10:04:28.742 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--5000 2020-08-05 10:04:29.744 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--3998 2020-08-05 10:04:30.746 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--2997 2020-08-05 10:04:31.746 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--1996 2020-08-05 10:04:32.747 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--995 2020-08-05 10:04:33.747 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onFinish 2020-08-05 10:04:45.397 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--4999 2020-08-05 10:04:46.398 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--3998 2020-08-05 10:04:47.400 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--2996 2020-08-05 10:04:48.402 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--1994 2020-08-05 10:04:49.405 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onTick--992 2020-08-05 10:04:50.401 17266-17266/com.yc.yctimer I/CountDownTimer: ----倒计时----onFinish 可以看到有几个问题: 问题1. 每次 onTick() 都会有几毫秒的误差,并不是期待的准确的 "5000, 4000, 3000, 2000, 1000, 0"。 问题2. 多运行几次,就会发现这几毫秒的误差,导致了计算得出的剩余秒数并不准确,如果你的倒计时需要显示剩余秒数,就会发生 秒数跳跃/缺失 的情况(比如一开始从“4”开始显示——缺少“5”,或者直接从“5”跳到了“3”——缺少“4”)。 问题3. 最后一次 onTick() 到 onFinish() 的间隔通常超过了 1 秒,差不多是 2 秒左右。如果你的倒计时在显示秒数,就能很明显的感觉到最后 1 秒停顿的时间很长。 03.3 分析时间误差 为什么会存在这个问题 先看start()方法,计算的 mStopTimeInFuture(未来停止倒计时的时刻,即倒计时结束时间) 加了一个 SystemClock.elapsedRealtime() ,系统自开机以来(包括睡眠时间)的毫秒数,也可以叫“系统时间戳”。 即倒计时结束时间为“当前系统时间戳 + 你设置的倒计时时长 mMillisInFuture ”,也就是计算出的相对于手机系统开机以来的一个时间。在下面代码中打印日志看看 public synchronized final void start() { if (mMillisInFuture <= 0 && mCountdownInterval <= 0) { throw new RuntimeException("you must set the millisInFuture > 0 or countdownInterval >0"); } mCancelled = false; long elapsedRealtime = SystemClock.elapsedRealtime(); mStopTimeInFuture = elapsedRealtime + mMillisInFuture; CountTimeTools.i("start → mMillisInFuture = " + mMillisInFuture + ", seconds = " + mMillisInFuture / 1000 ); CountTimeTools.i("start → elapsedRealtime = " + elapsedRealtime + ", → mStopTimeInFuture = " + mStopTimeInFuture); mPause = false; mHandler.sendMessage(mHandler.obtainMessage(MSG)); if (mCountDownListener!=null){ mCountDownListener.onStart(); } } @SuppressLint("HandlerLeak") private Handler mHandler = new Handler() { @Override public void handleMessage(@NonNull Message msg) { synchronized (CountDownTimer.this) { if (mCancelled) { return; } //剩余毫秒数 final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime(); if (millisLeft <= 0) { mCurrentMillisLeft = 0; if (mCountDownListener != null) { mCountDownListener.onFinish(); CountTimeTools.i("onFinish → millisLeft = " + millisLeft); } } else if (millisLeft < mCountdownInterval) { mCurrentMillisLeft = 0; CountTimeTools.i("handleMessage → millisLeft < mCountdownInterval !"); // 剩余时间小于一次时间间隔的时候,不再通知,只是延迟一下 sendMessageDelayed(obtainMessage(MSG), millisLeft); } else { //有多余的时间 long lastTickStart = SystemClock.elapsedRealtime(); CountTimeTools.i("before onTick → lastTickStart = " + lastTickStart); CountTimeTools.i("before onTick → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000 ); if (mCountDownListener != null) { mCountDownListener.onTick(millisLeft); CountTimeTools.i("after onTick → elapsedRealtime = " + SystemClock.elapsedRealtime()); } mCurrentMillisLeft = millisLeft; // 考虑用户的onTick需要花费时间,处理用户onTick执行的时间 long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime(); CountTimeTools.i("after onTick → delay1 = " + delay); // 特殊情况:用户的onTick方法花费的时间比interval长,那么直接跳转到下一次interval // 注意,在onTick回调的方法中,不要做些耗时的操作 boolean isWhile = false; while (delay < 0){ delay += mCountdownInterval; isWhile = true; } if (isWhile){ CountTimeTools.i("after onTick执行超时 → delay2 = " + delay); } sendMessageDelayed(obtainMessage(MSG), delay); } } } }; 然后看一下日志 2020-08-05 13:36:02.475 8742-8742/com.yc.yctimer I/CountDownTimer: start → mMillisInFuture = 5000, seconds = 5 2020-08-05 13:36:02.475 8742-8742/com.yc.yctimer I/CountDownTimer: start → elapsedRealtime = 122669630, → mStopTimeInFuture = 122674630 2020-08-05 13:36:02.478 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 122669634 2020-08-05 13:36:02.478 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 4996, seconds = 4 2020-08-05 13:36:02.479 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 122669635 2020-08-05 13:36:02.479 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = 999 2020-08-05 13:36:03.480 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 122670636 2020-08-05 13:36:03.480 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 3994, seconds = 3 2020-08-05 13:36:03.483 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 122670639 2020-08-05 13:36:03.484 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = 996 2020-08-05 13:36:04.482 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 122671638 2020-08-05 13:36:04.483 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 2992, seconds = 2 2020-08-05 13:36:04.486 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 122671642 2020-08-05 13:36:04.486 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = 996 2020-08-05 13:36:05.485 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 122672641 2020-08-05 13:36:05.485 8742-8742/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 1989, seconds = 1 2020-08-05 13:36:05.488 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 122672644 2020-08-05 13:36:05.488 8742-8742/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = 997 2020-08-05 13:36:06.487 8742-8742/com.yc.yctimer I/CountDownTimer: handleMessage → millisLeft < mCountdownInterval ! 2020-08-05 13:36:07.481 8742-8742/com.yc.yctimer I/CountDownTimer: onFinish → millisLeft = -3 分析一下日志 倒计时 5 秒,而 onTick() 一共只执行了 4 次。分别是出现4,3,2,1 start() 启动计时时,mMillisInFuture = 5000。且根据当前系统时间戳(记为 elapsedRealtime0 = 122669630,开始 start() 倒计时时的系统时间戳)计算了倒计时结束时相对于系统开机时的时间点 mStopTimeInFuture。 此后到第一次进入 handleMessage() 时,中间经历了很短的时间 122669630 - 122669634 = 6 毫秒。 handleMessage() 这里精确计算了程序执行时间,虽然是第一次进入 handleMessage,也没有直接使用 mStopTimeInFuture,而是根据程序执行到此处时的 elapsedRealtime() (记为 elapsedRealtime1)来计算此时剩余的倒计时时长。 millisLeft = 4996,进入 else,执行 onTick()方法回调。所以第一次 onTick() 时,millisLeft = 4996,导致计算的剩余秒数是“4996/1000 = 4”,所以倒计时显示秒数是从“4”开始,而不是“5”开始。这便是前面提到的 问题1 和 问题2。 考虑用户的onTick需要花费时间,处理用户onTick执行的时间,于是便发出一个延迟delay时间的消息sendMessageDelayed(obtainMessage(MSG), delay);在日志里看到delay1 = 997 03.3 onTick耗时超时 上面分析到了用户的onTick需要花费时间,如果delay < 0则需要特殊处理,这个究竟是什么意思呢?下面来分析一下 分析一下下面这个while循环作用 // 考虑用户的onTick需要花费时间,处理用户onTick执行的时间 long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime(); CountTimeTools.i("after onTick → delay1 = " + delay); // 特殊情况:用户的onTick方法花费的时间比interval长,那么直接跳转到下一次interval while (delay < 0){ delay += mCountdownInterval; } CountTimeTools.i("after onTick → delay2 = " + delay); sendMessageDelayed(obtainMessage(MSG), delay); 如果这次 onTick() 执行时间太长,超过了 mCountdownInterval ,那么执行完 onTick() 后计算得到的 delay 是一个负数,此时直接跳到下一次 mCountdownInterval 间隔,让 delay + mCountdownInterval。 举一个例子来说一下,不然这里不太好理解 假如设定每 1000 毫秒执行一次 onTick()。假设第一次 onTick() 开始前时的相对于手机系统开机时间的剩余倒计时时长是 5000 毫秒, 执行完这次 onTick() 操作消耗了 1015 毫秒,超出了我们设定的 1000 毫秒的间隔,那么第一次计算的 delay = 1000 - 1015 = -15 < 0,那么负数意味着什么呢? 本来我们设定的 onTick() 调用间隔是 1000 毫秒,可是它执行完一次却用了 1015 毫秒,现在剩余倒计时还剩下 5000 - 1015 = 3985 毫秒,本来第二次 onTick() 按期望应该是在 4000 毫秒时开始执行的,可是此时第一次的 onTick() 却还未执行完。所以第二次 onTick() 就会被延迟 delay = -15 + 1000 = 985 毫秒,也就是到剩余 3000 毫秒时再执行了。 那么此时就会 3985 / 1000 = 3,就会从5过度到3;依次类推,后续的delay延迟985毫秒后执行sendMessageDelayed,会导致时间出现跳跃性变动。具体可以看一下下面的例子…… onTick()做耗时操作会出现什么情况 比如下面,看打印日志可知:4,2没有,这就意味着这个阶段没有执行到onTick()方法,而如果你在这个里有业务逻辑与时间节点有关,则可能会出现bug 2020-08-05 13:58:00.657 11912-11912/com.yc.yctimer I/CountDownTimer: start → mMillisInFuture = 5000, seconds = 5 2020-08-05 13:58:00.657 11912-11912/com.yc.yctimer I/CountDownTimer: start → elapsedRealtime = 123987813, → mStopTimeInFuture = 123992813 2020-08-05 13:58:01.781 11912-11912/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 123988937 2020-08-05 13:58:01.781 11912-11912/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 3876, seconds = 3 2020-08-05 13:58:02.858 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 123990014 2020-08-05 13:58:02.858 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = -77 2020-08-05 13:58:02.858 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick执行超时 → delay2 = 923 2020-08-05 13:58:03.784 11912-11912/com.yc.yctimer I/CountDownTimer: before onTick → lastTickStart = 123990940 2020-08-05 13:58:03.784 11912-11912/com.yc.yctimer I/CountDownTimer: before onTick → millisLeft = 1873, seconds = 1 2020-08-05 13:58:04.896 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick → elapsedRealtime = 123992052 2020-08-05 13:58:04.896 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick → delay1 = -112 2020-08-05 13:58:04.896 11912-11912/com.yc.yctimer I/CountDownTimer: after onTick执行超时 → delay2 = 888 2020-08-05 13:58:05.788 11912-11912/com.yc.yctimer I/CountDownTimer: onFinish → millisLeft = -130 onTick方法中如何执行耗时操作【大于1秒的执行代码】 建议使用handler消息机制进行处理,避免出现其他问题。 03.4 代码改进完善 针对 问题1 和 问题 2: 问题描述 问题1. 每次 onTick() 都会有几毫秒的误差,并不是期待的准确的 "5000, 4000, 3000, 2000, 1000, 0"。 问题2. 多运行几次,就会发现这几毫秒的误差,导致了计算得出的剩余秒数并不准确,如果你的倒计时需要显示剩余秒数,就会发生 秒数跳跃/缺失 的情况(比如一开始从“4”开始显示——缺少“5”,或者直接从“5”跳到了“3”——缺少“4”)。 解决方案 这2个问题可以放在一起处理,网上也有很多人对这里做了改进,那就是给我们的 倒计时时长扩大一点点,通常是手动将 mMillisInFuture 扩大几十毫秒 效果 这里多加了 20 毫秒,运行一下(举个栗子)。倒计时打印日志:“5,4,3,2,1,finish”, 04.Timer和TimerTask解读 04.1 Timer和TimerTask方法 Timer核心方法如下所示 //安排指定任务在指定时间执行。如果时间在过去,任务被安排立即执行。 void schedule(TimerTask task, long delay) //将指定的任务调度为重复执行<i>固定延迟执行</i>,从指定的延迟开始。后续执行大约按按指定周期间隔的规则间隔进行。 void schedule(TimerTask task, long delay, long period) 第一个方法只执行一次; 第二个方式每隔period执行一次,delay表示每次执行的延时时间,其实主要表现在第一次的延时效果,比如delay设置为0,那么立马执行task内容,如果设置为1000,那么第一次执行task会有一秒的延时效果。 TimerTask核心方法 TimerTask用于继承(或者直接定义并初始化匿名类),并重写run方法,定义自己的业务逻辑。 //取消此计时器任务。如果任务被计划为一次性执行而尚未运行,或尚未被计划,则它将永远不会运行。 //如果任务被安排为重复执行,它将永远不会再运行。(如果在此调用发生时任务正在运行,则任务将运行到完成,但将不再运行。) public boolean cancel() { synchronized(lock) { boolean result = (state == SCHEDULED); state = CANCELLED; return result; } } 关于结束定时器 Timer和TimerTask都有cancel方法,而且最好同时调用;如果已经cancel,下次必须创建新的Timer才能schedule。 public void destroyTimer() { if (mTimer != null) { mTimer.cancel(); mTimer = null; } if (mTimerTask != null) { mTimerTask.cancel(); mTimerTask = null; } } 可能存在的问题 如果你在当前的activity中schedule了一个task,但是没有等到task结束,就按Back键finish了当前的activity,Timer和TimerTask并不会自动cancel或者销毁,它还会在后台运行,此时如果你在task的某个阶段要调起一个控件(比如AlertDialog),而该控制依赖被销毁的activity,那么将会引发crash。 所以建议在页面销毁的时候,将Timer和TimerTask都有cancel结束并且设置成null Timer 的方式实现定时任务,用来做倒计时是没有问题的。但是如果用来执行周期任务,恰好又有多个任务,恰好两个任务之间的时间间隔又比前一个任务执行时间短就会发生定时不准确的现象了。Timer 在执行过程中如果任务跑出了异常,Timer 会停止所有的任务。Timer 执行周期任务时依赖系统时间,系统时间的变化会引起 Timer 任务执行的变化。 04.2 Timer原理分析 其基本处理模型是单线程调度的任务队列模型,Timer不停地接受调度任务,所有任务接受Timer调度后加入TaskQueue,TimerThread不停地去TaskQueue中取任务来执行。 image 此种方式的不足之处为当某个任务执行时间较长,以致于超过了TaskQueue中下一个任务开始执行的时间,会影响整个任务执行的实时性。为了提高实时性,可以采用多个消费者一起消费来提高处理效率,避免此类问题的实现。 04.3 TimerTask分析 源代码如下所示 可以发现TimerTask是实现Runnable接口的一个抽象类。如果直接继承该类并且实现该类的run() 方法就可以了,里面包含这种对应的状态。 public abstract class TimerTask implements Runnable { final Object lock = new Object(); int state = VIRGIN; //表示尚未计划此任务(也表示初始状态) static final int VIRGIN = 0; //表示正在执行任务状态 static final int SCHEDULED = 1; //表示执行完成状态 static final int EXECUTED = 2; //取消状态 static final int CANCELLED = 3; //下次执行任务的时间 long nextExecutionTime; //执行时间间隔 long period = 0; //子类需要实现该方法,执行的任务的代码在该方法中实现 public abstract void run(); //取消任务,从这里我们可以很清楚知道取消任务就是修改状态 public boolean cancel() { synchronized(lock) { boolean result = (state == SCHEDULED); state = CANCELLED; return result; } } } 04.4 Timer源码分析 Timer才是真正的核心,在创建Timer对象的同时也创建一个TimerThread对象,该类集成Thread,本质上就是开启了一个线程。 public class Timer { //创建一个任务队列 private final TaskQueue queue = new TaskQueue(); //创建一个Thread线程对象,并且将queue队列传进去 private final TimerThread thread = new TimerThread(queue); public Timer() { this("Timer-" + serialNumber()); } public Timer(boolean isDaemon) { this("Timer-" + serialNumber(), isDaemon); } public Timer(String name) { thread.setName(name); thread.start(); } public Timer(String name, boolean isDaemon) { thread.setName(name); thread.setDaemon(isDaemon); thread.start(); } } 然后看一下TimerThread线程的源码,如下所示 首先看run方法中的mainLoop(),开启一个不断循环的线程如果队列中不存在任务则阻塞当前的线程,直到队列中添加任务以后唤醒线程。 然后获取队列中执行时间最小的任务,如果该任务的状态是取消的话则从队列中移除掉再从队列中重新获取。 最后判断当前的时间是否大于等于任务的执行的时间,如果任务的执行时间还未到则当前线程再阻塞一段时间,同时我们还要将该任务重新扔到任务队列中重新排序,我们必须保证队列中的第一个任务的执行时间是最小的。 执行完mainLoop()方法完后,接着就将newTasksMayBeScheduled设置为false,并且清空队列中所有的任务。 思考一下,这里的最小任务是什么意思?先把这个疑问记着…… class TimerThread extends Thread { boolean newTasksMayBeScheduled = true; private TaskQueue queue; TimerThread(TaskQueue queue) { this.queue = queue; } public void run() { try { mainLoop(); } finally { synchronized(queue) { //同时将状态置为false newTasksMayBeScheduled = false; //清空队列中所有的任务 queue.clear(); } } private void mainLoop() { //while死循环 while (true) { try { TimerTask task; boolean taskFired; synchronized(queue) { //如果任务队列为空并且该标志位 true的话,则该线程一直进行等待中,直到队列中有任务进来的时候执行 queue.notify才会解除阻塞 while (queue.isEmpty() && newTasksMayBeScheduled) queue.wait(); //如果队列中的内容为空的话直接跳出循环,外部调用者可能取消了Timer if (queue.isEmpty()) break; long currentTime, executionTime; //获取队列中最近执行时间最小的任务(也就是最近需要执行的任务) task = queue.getMin(); synchronized(task.lock) { //如果该任务的状态是取消状态的话,那从队列中移除这个任务,然后继续执行循环队列操作 if (task.state == TimerTask.CANCELLED) { queue.removeMin(); continue; } //获取当前系统时间 currentTime = System.currentTimeMillis(); //获取下一个目标要执行的时间 executionTime = task.nextExecutionTime; //如果下一个目标要执行的时间大于等于等于时间了,表示要执行任务了 if (taskFired = (executionTime<=currentTime)) { //如果task的时间间隔为0,表示只执行一次该任务 if (task.period == 0) { //将任务状态改为已执行状态,同时从队列中删除该任务 queue.removeMin(); task.state = TimerTask.EXECUTED; } else { //将任务重新跟队列中的任务进行排列,要始终保证第一个task的时间是最小的 queue.rescheduleMin(task.period<0 ? currentTime - task.period : executionTime + task.period); } } } //这里表示最近要执行的任务时间没有到,那么再让当前的线程阻塞一段时间 if (!taskFired) queue.wait(executionTime - currentTime); } //表示要执行的任务时间已经到了,那么直接调用任务的run() 执行代码 if (taskFired) task.run(); } catch(InterruptedException e) { } } } } 接着再来看一下TaskQueue队列的源代码 可以发现这个队列使用数组实现的,如果超过了128的话则扩容为原来的两倍。这个代码不多,注释写的很详细了,没什么好讲的…… public class TaskQueue { //创建一个数组为128的数组存放需要执行的任务,如果超过了128的话则扩容为原来的两倍 private TimerTask[] queue = new TimerTask[128]; //用于统计队列中任务的个数 private int size = 0; //返回队列中任务的个数 int size() { return size; } //依次遍历数组中的任务,并且置为null,有利于内存回收,注意这里的下标是从1开始计算的,不是从0 void clear() { for (int i=1; i<=size; i++) queue[i] = null; size = 0; } //这里添加一个新的元素使用的是最小堆的操作,这里不详细说明了。 void add(TimerTask task) { //如果数组已经存满任务,那么扩容一个新的数组为之前的两倍 if (size + 1 == queue.length) queue = Arrays.copyOf(queue, 2*queue.length); queue[++size] = task; fixUp(size); } private void fixUp(int k) { while (k > 1) { int j = k >> 1; if (queue[j].nextExecutionTime <= queue[k].nextExecutionTime) break; TimerTask tmp = queue[j]; queue[j] = queue[k]; queue[k] = tmp; k = j; } } } 04.5 schedule发布任务 当我们创建好Timer并且启动了循环线程以后,这个时候我们就需要发布任务。发布任务主要有以下几个方法。 schedule(TimerTask task, Date time) 表示第一次执行任务的时间,时间间隔为0,也表示该任务只执行一次就结束了 schedule(TimerTask task, Date firstTime, long period) firstTime 表示第一次执行的时间,period表示执行任务的时间间隔也就是多久时间执行一次 schedule(TimerTask task, long delay) 延迟 delay时间执行任务,也就是在当前的时间+delay执行任务(该方法只执行一次任务) 上面这三个方法都会执行sched方法,然后看一下这个 sched(TimerTask task, long time, long period) 上面所有的执行任务的函数最后都是调用的该方法,task表示要执行的任务,time表示要执行任务的时间,period表示任务执行的间隔时间。 具体看一下源代码 private void sched(TimerTask task, long time, long period) { //如果时间间隔大于 long最大值的一般的话,需要对该数值 /2 if (Math.abs(period) > (Long.MAX_VALUE >> 1)) period >>= 1; synchronized(queue) { //首先判断轮训线程是否取消,如果取消状态直接抛出异常 if (!thread.newTasksMayBeScheduled) throw new IllegalStateException("Timer already cancelled."); synchronized(task.lock) { //判断新执行的任务状态如果不是初始化状态话,直接抛出异常 if (task.state != TimerTask.VIRGIN) throw new IllegalStateException("Task already scheduled or cancelled"); //赋值下次执行任务的时间 task.nextExecutionTime = time; task.period = period; //将任务状态修改为发布状态 task.state = TimerTask.SCHEDULED; } //将任务添加到最小堆队列中,注意:这里在添加到队列里面要保证第一个元素始终是最小的 queue.add(task); //如果task就是队列中最小的任务话,则直接唤醒轮训线程执行任务(也就是唤醒TimerThread线程) if (queue.getMin() == task) queue.notify(); } } 从上面的代码中可以清楚的明白发布任务非常简单的,就是往任务队列中添加任务然后判断条件是否需要唤醒轮训线程去执行任务。其核心代码是在 TimerThread 轮训中以及使用最小堆实现的队列保证每次取出来的第一个任务的执行时间是最小的。 04.6 存在的问题分析 Timer通过一个寻轮线程循环的从队列中获取需要执行的任务,如果任务的执行时间未到则进行等待(通过Object类的 wait 方法实现阻塞等待)一段时间再自动唤醒执行任务。 但是细心的我们发现这个是单线程执行的如果有多个任务需要执行的话会不会应付不过来呢?类似一个程序员,要开发多个需求,要是所有的事情所耗费的时间很短的话,那么就不会出现延迟问题,要是其中一件或者是某件事情非常耗时间的话那么则会影响到后面事情的时间。 其实这个现象一样跟Timer出现的问题也是一样的道理,如果某个任务非常耗时间,而且任务队列中的任务又比较多的话,那 TimerThread 是忙不过来的,这样子就会导致后面的任务出现延迟执行的问题,进而会影响所有的定时任务的准确执行时间。 那么有人就会想要可以一个TimerTask对应一个Timer不就行了吗?但是我们要清楚的明白计算机的系统资源是有限的,如果我们一个任务就去单独的开一个轮训线程执行的话,其实是有一点浪费系统的资源的,完全没有必要的,如果不需要定时任务了话,我们还需要去销毁线程释放资源的,如果是这样子的反复操作的话,不利于我们程序的流畅性。 05.自定义倒计时器案例 为了方便实现倒计时器自由灵活设置,且代码精简,能够适应一个页面创建多个定时器。或者用在列表中,同时倒计时器支持暂停,恢复倒计时等功能。这个就需要做特使处理呢。 public class CountDownTimer { /** * 时间,即开始的时间,通俗来说就是倒计时总时间 */ private long mMillisInFuture; /** * 布尔值,表示计时器是否被取消 * 只有调用cancel时才被设置为true */ private boolean mCancelled = false; /** * 用户接收回调的时间间隔,一般是1秒 */ private long mCountdownInterval; /** * 记录暂停时候的时间 */ private long mStopTimeInFuture; /** * mas.what值 */ private static final int MSG = 520; /** * 暂停时,当时剩余时间 */ private long mCurrentMillisLeft; /** * 是否暂停 * 只有当调用pause时,才设置为true */ private boolean mPause = false; /** * 监听listener */ private TimerListener mCountDownListener; /** * 是否创建开始 */ private boolean isStart; public CountDownTimer(){ isStart = true; } public CountDownTimer(long millisInFuture, long countdownInterval) { long total = millisInFuture + 20; this.mMillisInFuture = total; //this.mMillisInFuture = millisInFuture; this.mCountdownInterval = countdownInterval; isStart = true; } /** * 开始倒计时,每次点击,都会重新开始 */ public synchronized final void start() { if (mMillisInFuture <= 0 && mCountdownInterval <= 0) { throw new RuntimeException("you must set the millisInFuture > 0 or countdownInterval >0"); } mCancelled = false; long elapsedRealtime = SystemClock.elapsedRealtime(); mStopTimeInFuture = elapsedRealtime + mMillisInFuture; CountTimeTools.i("start → mMillisInFuture = " + mMillisInFuture + ", seconds = " + mMillisInFuture / 1000 ); CountTimeTools.i("start → elapsedRealtime = " + elapsedRealtime + ", → mStopTimeInFuture = " + mStopTimeInFuture); mPause = false; mHandler.sendMessage(mHandler.obtainMessage(MSG)); if (mCountDownListener!=null){ mCountDownListener.onStart(); } } /** * 取消计时器 */ public synchronized final void cancel() { if (mHandler != null) { //暂停 mPause = false; mHandler.removeMessages(MSG); //取消 mCancelled = true; } } /** * 按一下暂停,再按一下继续倒计时 */ public synchronized final void pause() { if (mHandler != null) { if (mCancelled) { return; } if (mCurrentMillisLeft < mCountdownInterval) { return; } if (!mPause) { mHandler.removeMessages(MSG); mPause = true; } } } /** * 恢复暂停,开始 */ public synchronized final void resume() { if (mMillisInFuture <= 0 && mCountdownInterval <= 0) { throw new RuntimeException("you must set the millisInFuture > 0 or countdownInterval >0"); } if (mCancelled) { return; } //剩余时长少于 if (mCurrentMillisLeft < mCountdownInterval || !mPause) { return; } mStopTimeInFuture = SystemClock.elapsedRealtime() + mCurrentMillisLeft; mHandler.sendMessage(mHandler.obtainMessage(MSG)); mPause = false; } @SuppressLint("HandlerLeak") private Handler mHandler = new Handler() { @Override public void handleMessage(@NonNull Message msg) { synchronized (CountDownTimer.this) { if (mCancelled) { return; } //剩余毫秒数 final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime(); if (millisLeft <= 0) { mCurrentMillisLeft = 0; if (mCountDownListener != null) { mCountDownListener.onFinish(); CountTimeTools.i("onFinish → millisLeft = " + millisLeft); } } else if (millisLeft < mCountdownInterval) { mCurrentMillisLeft = 0; CountTimeTools.i("handleMessage → millisLeft < mCountdownInterval !"); // 剩余时间小于一次时间间隔的时候,不再通知,只是延迟一下 sendMessageDelayed(obtainMessage(MSG), millisLeft); } else { //有多余的时间 long lastTickStart = SystemClock.elapsedRealtime(); CountTimeTools.i("before onTick → lastTickStart = " + lastTickStart); CountTimeTools.i("before onTick → millisLeft = " + millisLeft + ", seconds = " + millisLeft / 1000 ); if (mCountDownListener != null) { mCountDownListener.onTick(millisLeft); CountTimeTools.i("after onTick → elapsedRealtime = " + SystemClock.elapsedRealtime()); } mCurrentMillisLeft = millisLeft; // 考虑用户的onTick需要花费时间,处理用户onTick执行的时间 // 打印这个delay时间,大概是997毫秒 long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime(); CountTimeTools.i("after onTick → delay1 = " + delay); // 特殊情况:用户的onTick方法花费的时间比interval长,那么直接跳转到下一次interval // 注意,在onTick回调的方法中,不要做些耗时的操作 boolean isWhile = false; while (delay < 0){ delay += mCountdownInterval; isWhile = true; } if (isWhile){ CountTimeTools.i("after onTick执行超时 → delay2 = " + delay); } sendMessageDelayed(obtainMessage(MSG), delay); } } } }; /** * 设置倒计时总时间 * @param millisInFuture 毫秒值 */ public void setMillisInFuture(long millisInFuture) { long total = millisInFuture + 20; this.mMillisInFuture = total; } /** * 设置倒计时间隔值 * @param countdownInterval 间隔,一般设置为1000毫秒 */ public void setCountdownInterval(long countdownInterval) { this.mCountdownInterval = countdownInterval; } /** * 设置倒计时监听 * @param countDownListener listener */ public void setCountDownListener(TimerListener countDownListener) { this.mCountDownListener = countDownListener; } } 如何使用 //开始 mCountDownTimer.start(); //结束销毁 mCountDownTimer.cancel(); //暂停 mCountDownTimer.pause(); //恢复暂停 mCountDownTimer.resume(); 代码案例:https://github.com/yangchong211/YCTimer
目录介绍 01.loadUrl到底做了什么 02.触发加载网页的行为 03.webView重定向怎么办 04.js交互的一点知识分享 05.拦截缓存如何优雅处理 06.关于一些问题和优化 07.关于一点面向对象思想 08.关于后期需要研究目标 01.loadUrl到底做了什么 WebView.loadUrl(url)加载网页做了什么? 加载网页是一个复杂的过程,在这个过程中,我们可能需要执行一些操作,包括: 加载网页前,重置WebView状态以及与业务绑定的变量状态。WebView状态包括重定向状态(mTouchByUser)、前端控制的回退栈(mBackStep)等,业务状态包括进度条、当前页的分享内容、分享按钮的显示隐藏等。 加载网页前,根据不同的域拼接本地客户端的参数,包括基本的机型信息、版本信息、登录信息以及埋点使用的Refer信息等,有时候涉及交易、财产等还需要做额外的配置。 开始执行页面加载操作时,会回调WebViewClient.onPageStarted(webView,url,favicon)。在此方法中,可以重置重定向保护的变量(mRedirectProtected),当然也可以在页面加载前重置,由于历史遗留代码问题,此处尚未省去优化。 加载页面的过程中回调哪些方法? WebChromeClient.onReceivedTitle(webview, title),用来设置标题。需要注意的是,在部分Android系统版本中可能会回调多次这个方法,而且有时候回调的title是一个url,客户端可以针对这种情况进行特殊处理,避免在标题栏显示不必要的链接。 WebChromeClient.onProgressChanged(webview, progress),根据这个回调,可以控制进度条的进度(包括显示与隐藏)。一般情况下,想要达到100%的进度需要的时间较长(特别是首次加载),用户长时间等待进度条不消失必定会感到焦虑,影响体验。其实当progress达到80的时候,加载出来的页面已经基本可用了。事实上,国内厂商大部分都会提前隐藏进度条,让用户以为网页加载很快。 WebViewClient.shouldInterceptRequest(webview, request),无论是普通的页面请求(使用GET/POST),还是页面中的异步请求,或者页面中的资源请求,都会回调这个方法,给开发一次拦截请求的机会。在这个方法中,我们可以进行静态资源的拦截并使用缓存数据代替,也可以拦截页面,使用自己的网络框架来请求数据。包括后面介绍的WebView免流方案,也和此方法有关。 WebViewClient.shouldOverrideUrlLoading(webview, request),如果遇到了重定向,或者点击了页面中的a标签实现页面跳转,那么会回调这个方法。可以说这个是WebView里面最重要的回调之一,后面WebView与Native页面交互一节将会详细介绍这个方法。 WebViewClient.onReceivedError(webview,handler,error),加载页面的过程中发生了错误,会回调这个方法。主要是http错误以及ssl错误。在这两个回调中,我们可以进行异常上报,监控异常页面、过期页面,及时反馈给运营或前端修改。在处理ssl错误时,遇到不信任的证书可以进行特殊处理,例如对域名进行判断,针对自己公司的域名“放行”,防止进入丑陋的错误证书页面。也可以与Chrome一样,弹出ssl证书疑问弹窗,给用户选择的余地。 加载页面结束回调哪些方法 会回调WebViewClient.onPageFinished(webview,url)。 这时候可以根据回退栈的情况判断是否显示关闭WebView按钮。通过mActivityWeb.canGoBackOrForward(-1)判断是否可以回退。 02.触发加载网页的行为 触发加载网页的行为主要有两种方式: (A)点击页面,触发标签。 (B)调用WebView的loadUrl()方法 这两种方法都会发出一条地址,区别就在于这条地址是目的地址还是重定向地址。以访问http://www.baidu.com百度的页面来测试一下方法的执行顺序。 触发加载网页流程分析 在代码中通过loadUrl加载百度的首页,此时的行为属于(B)方式。 可以发现大概的执行顺序是:onPageStarted ——> shouldOverrideUrlLoading ——> onPageFinished 那么为什么会执行多次呢,思考一下?具体可以看一下7.2得出的结论分析。 X5LogUtils: -------onPageStarted-------http://www.baidu.com/ X5LogUtils: -------shouldOverrideUrlLoading-------https://m.baidu.com/?from=844b&vit=fps X5LogUtils: -------onPageFinished-------http://www.baidu.com/ X5LogUtils: -------onPageStarted-------https://m.baidu.com/?from=844b&vit=fps X5LogUtils: -------onReceivedTitle-------百度一下 X5LogUtils: -------shouldOverrideUrlLoading-------http://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onPageFinished-------https://m.baidu.com/?from=844b&vit=fps X5LogUtils: -------shouldOverrideUrlLoading-------https://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onPageStarted-------http://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onPageFinished-------http://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onPageStarted-------https://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onReceivedTitle-------百度一下,你就知道 X5LogUtils: -------onPageFinished-------https://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump 在首页,点击一下“hao123”,跳转到www.hao123.com的主页上来,此时的行为属于(A)方式。 可以发现大概的执行顺序是:shouldOverrideUrlLoading ——> onPageStarted ——> onPageFinished X5LogUtils: -------shouldOverrideUrlLoading-------http://m.hao123.com/?ssid=0&from=844b&bd_page_type=1&uid=0&pu=sz%401321_1002%2Cta%40utouch_2_9.0_2_6.2&idx=30000&itj=39 X5LogUtils: -------onPageStarted-------http://m.hao123.com/?ssid=0&from=844b&bd_page_type=1&uid=0&pu=sz%401321_1002%2Cta%40utouch_2_9.0_2_6.2&idx=30000&itj=39 X5LogUtils: -------onReceivedTitle-------hao123导航-上网从这里开始 X5LogUtils: -------onPageFinished-------http://m.hao123.com/?ssid=0&from=844b&bd_page_type=1&uid=0&pu=sz%401321_1002%2Cta%40utouch_2_9.0_2_6.2&idx=30000&itj=39 然后在hao123页面,点击优酷网进行跳转,此时的行为属于(A)方式。 X5LogUtils: -------shouldOverrideUrlLoading-------http://m.hao123.com/j.php?z=2&page=index_cxv3&pos=cydhwt_n2&category=ty&title=%E4%BC%98%E9%85%B7%E7%BD%91&qt=tz&url=http%3A%2F%2Fwww.youku.com%2F&key=58193753e7a868d9a013056c6c4cd77b X5LogUtils: -------onPageStarted-------http://m.hao123.com/j.php?z=2&page=index_cxv3&pos=cydhwt_n2&category=ty&title=%E4%BC%98%E9%85%B7%E7%BD%91&qt=tz&url=http%3A%2F%2Fwww.youku.com%2F&key=58193753e7a868d9a013056c6c4cd77b X5LogUtils: -------shouldOverrideUrlLoading-------http://www.youku.com/ X5LogUtils: -------onPageFinished-------http://m.hao123.com/j.php?z=2&page=index_cxv3&pos=cydhwt_n2&category=ty&title=%E4%BC%98%E9%85%B7%E7%BD%91&qt=tz&url=http%3A%2F%2Fwww.youku.com%2F&key=58193753e7a868d9a013056c6c4cd77b X5LogUtils: -------onPageStarted-------http://www.youku.com/ X5LogUtils: -------shouldOverrideUrlLoading-------https://www.youku.com/ X5LogUtils: -------onPageFinished-------http://www.youku.com/ X5LogUtils: -------onPageStarted-------https://www.youku.com/ X5LogUtils: -------onReceivedTitle-------优酷视频-首页 X5LogUtils: -------onPageFinished-------https://www.youku.com/ 然后从优酷页面回退到hao123页面,看看又回执行哪些方法。 X5LogUtils: -------onPageStarted-------http://m.hao123.com/?ssid=0&from=844b&bd_page_type=1&uid=0&pu=sz%401321_1002%2Cta%40utouch_2_9.0_2_6.2&idx=30000&itj=39 X5LogUtils: -------onReceivedTitle-------hao123导航-上网从这里开始 X5LogUtils: -------onReceivedTitle-------hao123导航-上网从这里开始 X5LogUtils: -------onPageFinished-------http://m.hao123.com/?ssid=0&from=844b&bd_page_type=1&uid=0&pu=sz%401321_1002%2Cta%40utouch_2_9.0_2_6.2&idx=30000&itj=39 然后从hao123页面回退到百度首页,看看又回执行哪些方法。 X5LogUtils: -------onPageStarted-------https://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump X5LogUtils: -------onReceivedTitle-------百度一下,你就知道 X5LogUtils: -------onReceivedTitle-------百度一下,你就知道 X5LogUtils: -------onPageFinished-------https://m.baidu.com/?cip=117.101.19.67&baiduid=C6FCEED198C994E0D653C094F2708C32&from=844b&vit=fps?from=844b&vit=fps&index=&ssid=0&bd_page_type=1&logid=12175252243175665635&pu=sz%401321_480&t_noscript=jump 得出结论分析说明 在(A)行为方式下(用户点击链接的回调): 1.如果是目的地址,那么方法的执行顺序是: shouldOverrideUrlLoading() -> onPageStarted()-> onPageFinished() shouldOverrideUrlLoading()由于它要提供给APP选择加载网页环境的机会,所以只要是网页上地址请求,都会获取到。 2.如果是重定向地址,在跳转到目的地址之前会进行不断的地址定位,每一次地址定位都会由以下执行顺序体现出来: onPageStarted()->shouldOverrideUrlLoading()->onPageFinished() 暂且设定这种执行顺序叫:fixed position 那么一个正常的重定向地址,方法的执行顺序就是: shouldOverrideUrlLoading() -> fixed position -> … -> fixed position -> onPageStarted() -> onPageFinished() 举个例子:有重定向(A->B->C),那么 shouldOverrideUrlLoading(A) -> onPageStarted(A) -> onPageStarted(B) -> shouldOverrideUrlLoading(B) -> onPageStarted(C) -> shouldOverrideUrlLoading(C) -> onPageFinished(C) 在(B)行为下: 1.如果是目的地址,那么方法的执行顺序是: onPageStarted()-> onPageFinished() loadUrl()加载地址时,一般不会触发shouldOverrideUrlLoading(),一旦触发了,就说明这是一个重定向地址。 2.如果是重定向地址,方法的执行顺序就是: fixed position -> … -> fixed position -> onPageStarted() -> onPageFinished() 03.webView重定向怎么办 webView出现302/303重定向 302重定向又称之为302代表暂时性转移,比如你跳转A页面,但由于网页添加了约束条件,可能让你跳转到B页面,甚至多次重定向。 导致的问题 1.A-->B-->C,比如你跳转A页面,最终重定向到C页面。这个时候调用goBack方法,返回到B链接,但是B链接又会跳转到C链接,从而导致没法返回到A链接界面 2.会多次执行onPageStarted和onPageFinished,如果你这里有加载进度条或者loading,那么会导致进度条或者loading执行多次 常见的解决方案 手动管理回退栈,遇到重定向时回退两次。 通过HitTestResult判断是否是重定向,从而决定是否自己加载url。具体看:16.301/302回退栈问题解决方案2 通过设置标记位,在onPageStarted和onPageFinished分别标记变量避免重定向。具体看:17.301/302回退栈问题解决方案3 通过用户的touch事件来判断重定向。具体看:15.301/302回退栈如何处理1 如何判断重定向 通过getHitTestResult()返回值,如果返回null,或者UNKNOWN_TYPE,则表示为重定向。具体看:18.如何用代码判断是否重定向 在加载一个页面开始的时候会回调onPageStarted方法,在该页面加载完成之后会回调onPageFinished方法。而如果该链接发生了重定向,回调shouldOverrideUrlLoading会在回调onPageFinished之前。 终极解决方案如下 需要准备的条件 创建一个栈,主要是用来存取和移除url的操作。这个url包括所有的请求链接 定义一个变量,用于判断页面是否处于正在加载中。 定义一个变量,用于记录重定向前的链接url 定一个重定向时间间隔,主要为了避免刷新造成循环重定向 具体怎么操作呢 在执行onPageStarted时,先移除栈中上一个url,然后将url加载到栈中。 当出现错误重定向的时候,如果和上一次重定向的时间间隔大于3秒,则reload页面。 在回退操作的时候,判断如果可以回退,则从栈中获取最后停留的url,然后loadUrl。即可解决回退问题。 具体方法思路 可以看:20.重定向终极优雅解决方案 具体代码看:X5WebViewClient 04.js交互的一点知识分享 js交互介绍 Java调用js方法有两种: WebView.loadUrl("javascript:" + javascript); WebView.evaluateJavascript(javascript, callbacck); js调用Java的方法有三种,分别是: JavascriptInterface WebViewClient.shouldOverrideUrlLoading() WebChromeClient.onJsPrompt() js调用java方法比较和区别分析 1.通过 addJavascriptInterface 方法进行添加对象映射。js最终通过对象调用原生方法 2.shouldOverrideUrlLoading拦截操作,获取scheme匹配,与网页约定好一个协议,如果匹配,执行相应操作 3.利用WebChromeClient回调接口onJsPrompt拦截操作。 onJsAlert 是不能返回值的,而 onJsConfirm 只能够返回确定或者取消两个值,只有 onJsPrompt 方法是可以返回字符串类型的值,操作最全面方便。 详细分析可以看:03.Js调用Android js调用java原生方法可能存在的问题? 提出问题 1.原生方法是否可以执行耗时操作,如果有会阻塞通信吗?4.4.8 prompt的一个坑导致js挂掉 2.多线程中调用多个原生方法,如何保证原生方法每一个都会被执行到? 3.js会阻塞等待当前原生函数(耗时操作的那个)执行完毕再往下走,所以js调用java方法里面最好也不要做耗时操作 解决方案 1.在js调用window.alert,window.confirm,window.prompt时,会调用WebChromeClient对应方法,可以此为入口,作为消息传递通道,考虑到开发习惯,一般不会选择alert跟confirm,通常会选prompt作为入口,在App中就是onJsPrompt作为jsbridge的调用入口。由于onJsPrompt是在UI线程执行,所以尽量不要做耗时操作,可以借助Handler灵活处理。 2.利用Handler封装一下,让每个任务自己处理,耗时的话就开线程自己处理。具体可以看:WvWebView java调用js的时机 onPageFinished()或者onPageStarted()方法中注入js代码吗? js交互,大部分都会认为js在WebViewClient.onPageFinished()方法中注入最合适,此时dom树已经构建完成,页面已经完全展现出来。但如果做过页面加载速度的测试,会发现WebViewClient.onPageFinished()方法通常需要等待很久才会回调(首次加载通常超过3s),这是因为WebView需要加载完一个网页里主文档和所有的资源才会回调这个方法。 能不能在WebViewClient.onPageStarted()中注入呢?答案是不确定。经过测试,有些机型可以,有些机型不行。在WebViewClient.onPageStarted()中注入还有一个致命的问题——这个方法可能会回调多次,会造成js代码的多次注入。 从7.0开始,WebView加载js方式发生了一些小改变,官方建议把js注入的时机放在页面开始加载之后。 可以在onProgressChanged中方法中注入js代码 页面的进度加载到80%的时候,实际上dom树已经渲染得差不多了,表明WebView已经解析了标签,这时候注入一般是成功的。 提到的多次注入控制,使用了boolean值变量控制;重新加载一个URL之前,需要重置boolean值变量,让重新加载后的页面再次注入js 05.拦截缓存如何优雅处理 WebView为何加载慢 webView是怎么加载网页的呢? webView初始化->DOM下载→DOM解析→CSS请求+下载→CSS解析→渲染→绘制→合成 渲染速度慢 前端H5页面渲染的速度取决于 两个方面: Js 解析效率。Js 本身的解析过程复杂、解析速度不快 & 前端页面涉及较多 JS 代码文件,所以叠加起来会导致 Js 解析效率非常低 手机硬件设备的性能。由于Android机型碎片化,这导致手机硬件设备的性能不可控,而大多数的Android手机硬件设备无法达到很好很好的硬件性能 页面资源加载缓慢 H5 页面从服务器获得,并存储在 Android手机内存里: H5页面一般会比较多 每加载一个 H5页面,都会产生较多网络请求: HTML 主 URL 自身的请求; HTML外部引用的JS、CSS、字体文件,图片也是一个独立的 HTTP 请求 每一个请求都串行的,这么多请求串起来,这导致 H5页面资源加载缓慢 解决WebView加载慢 前端H5的缓存机制(WebView 自带) 资源拦截缓存 资源拦截替换 webView浏览器缓存机制 这些技术都是协议层所定义的,在Android的webView当中我们可以通过配置决定是否采纳这几个协议的头部属性 // LOAD_CACHE_ONLY: 不使用网络,只读取本地缓存数据 // LOAD_DEFAULT: (默认)根据cache-control决定是否从网络上取数据。 // LOAD_NO_CACHE: 不使用缓存,只从网络获取数据. // LOAD_CACHE_ELSE_NETWORK,只要本地有,无论是否过期,或者no-cache,都使用缓存中的数据。 ws.setCacheMode(WebSettings.LOAD_DEFAULT); 一般设置为默认的缓存模式就可以了。关于缓存的配置, 主要还是靠web前端和后台设置。关于浏览器缓存机制 自身构建缓存方案 拦截处理 在shouldInterceptRequest方法中拦截处理 步骤1:判断拦截资源的条件,即判断url里的图片资源的文件名 步骤2:创建一个输入流,这里可以先从内存中拿,拿不到从磁盘中拿,再拿不到就从网络获取数据 步骤3:打开需要替换的资源(存放在assets文件夹里),或者从lru中取出缓存的数据 步骤4:替换资源 有几个问题 如何判断url中资源是否需要拦截,或者说是否需要缓存 如何缓存js,css等 缓存数据是否有时效性 关于缓存下载的问题,是引入okhttp还是原生网络请求,缓存下载失败该怎么处理 在哪里进行拦截 webView在加载网页的时候,用户能够通过系统提供的API干预各个中间过程。我们要拦截的就是网页资源请求的环节。这个过程,WebViewClient当中提供了以下两个入口: // android5.0以上的版本加入 @Override public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest webResourceRequest) { return super.shouldInterceptRequest(webView, webResourceRequest); } @Override public WebResourceResponse shouldInterceptRequest(WebView webView, String s) { return super.shouldInterceptRequest(webView, s); } 替换资源操作 只要在这两个入口构造正确的WebResourceResponse对象,就可以替换默认的请求为我们提供的资源 因此,在每次请求资源的时候根据请求的URL/WebResourceRequest判断是否存在本地的缓存,并在缓存存在的情况下将缓存的输入流返回 06.关于一些问题和优化 影响页面加载的一些因素有那些? 1.加载网页中,如果图片很多,而这些图片的请求又是一个个独立并且串行的请求。那么可能会导致加载页面比较缓慢…… 2.app原生和webView中请求,都会涉及到https的网络请求,那么在请求前会有域名dns的解析,这个也会有大约200毫秒的解析时间(主要耗费时间dns,connection,服务器处理等)…… 3.webView加载html网页时,有些js一直在执行比如动画之类的东西,此刻webView挂在了后台这些资源是不会被释放用户也无法感知。导致耗费资源…… 4.关于加载loading或者加载进度条,不一定要放到onPageStarted开始执行即显示出来,因为webView从创建到这个方法会有一个时间…… 5.webView默认开启密码保存功能,如果网页涉及到用户登陆,密码会被明文保到 /data/data/com.package.name/databases/webview.db 中,这样就有被盗取密码的危险…… 6.h5页面被拦截或者注入广告,重定向,或者DNS劫持。一般跟连接的wifi有关系(http劫持),也可能跟运营商有关系(dns劫持) 具体可以操作的优化分析 1.加载webView中的资源时,针对图片,等页面finish后再发起图片加载(也就是执行onPageFinished设置加载图片)。具体看5.0.2图片加载次序优化 2.DNS域名解析采用和客户端API相同的域名, DNS会在系统级别进行缓存,对于WebView的地址,如果使用的域名与native的API相同,则可以直接使用缓存的DNS而不用再发起请求图片。具体看5.0.7 DNS采用和客户端API相同的域名 3.在后台的时候,会调用onStop方法,即此时关闭js交互,回到前台调用onResume再开启js交互。具体看5.0.9 后台无法释放js导致发热耗电 4.提前显示进度条不是提升性能,但是对用户体验来说也是很重要的一点 ,WebView.loadUrl("url") 不会立马就回调onPageStarted方法,因为在这一时间段,WebView 有可能在初始化内核,也有可能在与服务器建立连接,这个时间段容易出现白屏 5.需要通过 WebSettings.setSavePassword(false) 关闭密码保存功能。 6.一般可以处理:1使用https代替http;2.添加白名单(比如添加自己网站的host,其他不给访问);3.对页面md5校验(不太好)。设置白名单参考:5.0.8 如何设置白名单操作 还有一些其他的优化小细节 a.WebView处理404、500逻辑,在WebChromeClient子类中可以重写他的onReceivedTitle()方法监听标题,还有在WebChromeClient子类中onReceivedHttpError可以监听statusCode。具体操作看5.1.5 WebView处理404、500逻辑 b.如果不显示图片,开发的时候可能使用的是https的链接, 但是链接中的图片可能是http的,需要开启设置。具体看:4.1.4 webView加载网页不显示图片 c.evaluateJavascript(String var1, ValueCallback var2)中url长度有限制,在19以上超过2097152个字符失效,这个地方可以加个判断。不过一般很难碰到……具体可以参考:4.3.1 Android与js传递数据大小有限制 d.在web页面android软键盘覆盖问题,常见的有android:windowSoftInputMode的值adjustPan或者adjustResize即可,如果webView是全屏模式则仍然会出现问题。具体看:4.6.1 在web页面android软键盘覆盖问题 e.关于WebView隐藏H5页面中的某个标签视图,大概操作就是在页面加载完成,通过getElementsByClassName找到h5中标签name,然后手动写function方法隐藏标签。但加载时机很关键,不过会造成闪屏和多次加载。具体看:4.6.6 WebView如何隐藏H5的部分内容问题 f.页面重定向,会导致onPageStarted多次执行,那么这个时候如何避免加载进度条出现执行多次,或者跳动的问题。具体可见:09.web进度条避免多次加载 g.建议开启Google安全浏览服务,用户访问不安全网页会提示安全问题;webView使用上的建议设置布局高度和宽度设置为 match_parent;具体可见48.开启Google安全浏览服务 07.关于一点面向对象的思想 针对webView视频播放演变 1.最刚开始把视频全屏show和hide的逻辑都放到X5WebChromeClient中处理,相当于这个类中逻辑比较多 2.后期把视频全屏播放逻辑都抽到了VideoWebChromeClient类中处理,这样只需要继承该类即可。这个类独立,拿来即用。 3.后期演变,一个视频全屏播放接口 + 接口实现类 + VideoChromeClient,接口主要能够解耦 关于webView拦截缓存处理 1.代码结构大概是:拦截缓存接口 + 接口实现类 + 接口委派类 2.优点:委派类和实现类解耦;便于增加过滤功能(比如用了https+dns优化就不用拦截缓存); //1.创建委托对象 WebViewCacheDelegate webViewCacheDelegate = WebViewCacheDelegate.getInstance(); //2.通过委托对象调用方法 WebResourceResponse webResourceResponse = webViewCacheDelegate.interceptRequest(url); 关于shouldOverrideUrlLoading处理多类型 比如:封装库中需要处理打电话,发短信,发邮件,地图定位,图片,超链接等拦截逻辑 最刚开始是把处理的逻辑都放到了WebViewClient中的shouldOverrideUrlLoading方法中处理。不过发现这个类代码越来越多…… 后期演变,针对电话短信等将处理逻辑抽取到WebSchemeIntent类中,针对图片处理逻辑抽取到SaveImageProcessor类中。具体看WebSchemeIntent 这样做,相当于保证了类的单一性职责,即类尽量保证内部处理的功能尽可能单一,而不是错综复杂…… 08.关于后期需要研究的目标 目标 web页面特别消耗流量,每次打开页面都会请求网络,建议对流量的消耗进行优化……除了对lib库中对拦截做OkHttp缓存,还有什么其他方案 web页面涉及流量的几个方面 普通https请求,一般过程是服务端(对象)-->网络中(二进制流)-->客户端(对象),文本内容会做传输压缩 网络图片下载,图片下载消耗的流量较多 h5页面展示,由于h5页面是交由前端处理显示,客户端开发关注的少些,而此处消耗了大量的流量 如何查看web页面消耗流量 使用TrafficStats即可查看流量的消耗 09.开源库 https://github.com/yangchong211/YCWebView
目录介绍 01.组件传值遇到坑 02.父组件传值给子组件 03.子组件传值给父组件 01.组件传值遇到坑 子组件给父组件传值注意点 注意子组件触发事件定义的方法,首先在父组件中需要绑定子组件内部对应事件,然后一定要和父控件接受的保持一致,否则无法传递数据。 //在area.vue中,进行事件触发,传递数据 this.$emit('onConfirm',true, selectVal) //在select-school.vue中,需要在在子组件标签上绑定子组件内部对应事件,并且方法名一致 <!-- 地区选择器 --> <optional :status='show' @onUpdate='onUpdate' @onConfirm='onConfirm'></optional> 遇到疑问? 要是同级的组件,那么该如何传递数据呢? 02.父组件传值给子组件 父组件的代码如下 <!-- 父组件传子组件 --> <!-- 父组件内部写法 --> <template> <view> <h2>父组件</h2> <!-- 绑定自定义属性传递数据 --> <children style="color: #0000FF;" :value="valPar" ></children> </view> </template> <script> //引入子组件 import children from "../../pages/ele/element-children1.vue" export default { data() { return { valPar:"父组件传递过来的值" } }, components:{ //注册子组件 children }, } </script> 子组件的代码如下 <!-- 父组件传子组件 --> <!-- 子组件内部写法 --> <template> <h2>子组件收到:{{value}}</h2> </template> <script> export default { props:{ value:{ type:String, default:"默认值" } }, data() { return { } }, } </script> 03.子组件传值给父组件 父组件的代码如下 <!-- 子组件传父组件 --> <!-- 父组件内部写法 --> <template> <view> <!-- 接收到子组件传递的数据 --> <h2>父组件接收到的值:{{valueChild}}</h2> <!-- 在子组件标签上绑定子组件内部对应事件,并触发对应回调 --> <children style="color: #0000FF;" @Transmit="handle"></children> </view> </template> <script> //引入子组件 import children from "../../pages/ele/element-children2.vue" export default { data() { return { //定义属性接收数据 valueChild:"", } }, components:{ //注册子组件 children }, methods:{ // 子组件内部触发事件对应回调handle handle(val){ this.valueChild=val; } } } </script> 子组件的代码如下 <!-- 子组件传父组件 --> <!-- 子组件内部写法 --> <template> <view> <h2>子组件</h2> <!-- 点击按钮进行事件触发 --> <button @click="handleTransmit">点击给父组件传值</button> </view> </template> <script> export default { data() { return { //要传递的数据 valueParent: "子组件传递过来的数据" } }, methods: { handleTransmit() { // 进行事件触发,传递数据 this.$emit("Transmit", this.valueParent) } } } </script>
目录介绍 01.遇到问题汇总 02.关于布局设置 03.基础语法总结 04.关于交互问题 06.关于回传数据 07.关于网络请求 08.关于页面刷新 09.关于注意问题 10.待解决和思考 01.遇到问题汇总 在我的页面,给item设置分割线时,定义view的class为line出现问题,但是把名称修改成cell-line就可以。猜想可能是设置class名称时,用line有冲突。 从A页面跳转B页面,关闭B返回到A,如何回传数据?看了往上方案,发现都有问题,最后用存取值替代。 比如切换页面布局视图刷新时,我的页面登陆,未登陆,会员,使用v-if替代v-show方式刷新页面。 网络请求,在学员信息页面,使用post提交数据,需要设置header请求头,否则会出现请求异常 数据绑定,比如动态改变view的背景颜色,建议用class设置替代style设置 在data中给字段赋值,建议赋值方式是''字符串,即使是整型。比如使用sex : "3"替代sex : 3 当父,子等多层控件都有点击事件的时候,为了避免冒泡事件冲突,可以加上@tap.stop阻止冒泡事件 图片引入,设置相对路径有时不生效,这是为什么?根据柯佳的规范文档,建议url的引入规则使用绝对路径 在省市区地区控件中,即使给scroll-view的父view设置了高度,仍然要给scroll-view设置高度,不然会撑满页面 02.关于布局设置 flex布局属性介绍 这个是边写布局,边查询 display: flex; //将对象作为弹性伸缩盒显示 display: inline-flex; //将对象作为内联块级弹性伸缩盒显示 父元素默认根据子元素宽高自适应 //主轴方向 flex-direction: row; //项目排列方向为水平方向,从左端开始 flex-direction: column; //主轴为垂直方向,起点在右端 //如何换行 flex-wrap: nowrap; //项目不换行排列 flex-wrap: wrap; //换行排列,第一行在上方 flex-wrap: reverse; //换行排列,第一行在下方 //主轴对齐方式 justify-content: flex-start //左对齐 justify-content: flex-end //右对齐 justify-content: center //居中 justify-content: space-between //两端对齐,项目之间间隔相等 justify-content: space-around //每个项目两侧间隔相等 //项目在交叉轴上对齐方式 align-items: center; //垂直居中 align-items: flex-start; //交叉轴起点对齐 align-items: flex-end; //交叉轴终点对齐 //多跟轴线的对齐方式 align-content: center; //垂直居中 align-content: flex-start; //交叉轴起点对齐 align-content: flex-end; //交叉轴终点对齐 常用的样式 position:sticky //粘性定位(基于用户的滚动位置来定位,使用时需指定特定阈值,如top:0) position:static //默认定位(没有定位) position:fixed //固定定位(固定在窗口位置,窗口滚动也不会移动) position:relative top:10px //相对定位(相对其正常位置定位) position:absolute //绝对定位(相对于最近的已定位父元素,如果没有已定位父元素,则相对于<html>) border-radius:30upx; //圆角半径 text-indent:20px //首行缩进 letter-spacing:1px //字间距 vertical-align: middle; //图片垂直居中 z-index //重叠元素的堆叠顺序 //https://www.cnblogs.com/skura23/p/6505352.html :active,元素被点击时变色,但颜色在点击后消失 :focus, 元素被点击后变色,且颜色在点击后不消失 css中font不支持简写 //错误 font:bold 28rpx; //正确 font-size:28rpx; font-weight:bold; scroll-view需要设置高度 在省市区地区控件中,给父view设置高度500rpx,如果不给地区scroll-view设置高度,则地区内容会盛满控件,这样会导致切换省市区页面抖动。 解决办法,给子scroll-view同样需要设置高度。 03.基础语法总结 v-if和v-show 比如在我的页面,有登陆状态,会员状态,还有未登陆状态,且布局可以动态隐藏和显示,这个时候就用到v-if v-if 和 v-show 的区别:前者是否会在dom中被移除,后者 display:none 针对刷新切换视图,比如登陆/为登陆,建议使用v-if。 使用场景如果需要非常频繁地切换,则使用 v-show 较好;如果在运行时条件很少改变,则使用 v-if 较好。更多内容阅读这篇文章 关于数据绑定 比如用户中心选择性别,选择切换颜色,需要注意书写规范。代码如下所示: //正确写法 <text class="cell-sex" :class="{'cell-sex-select': isSelcetMan}" @click="clickMan">男</text> <text class="cell-sex" :class="{'cell-sex-select': !isSelcetMan}" @click="clickWoman">女</text> //错误写法 <text :style="{color:isSelcetMan?'#F88B32':'#666666'}" class="cell-sex" @click="clickMan">男</text> <text :style="{color:!isSelcetMan?'#F88B32':'#666666'}" class="cell-sex" @click="clickWoman">女</text> 关于data中赋值注意 在学员信息页面,在data中设置sex为整型的时候,发现提交学员信息报500异常,但是如果赋值改成"3",就发现可以呢。难道是即使绑定的是数字,网页与移动端获取到的也只会是字符串 export default { data() { return { //性别,1男,2女 sex : "3", // sex : 3, }; }, } //提交学员信息,sex参数是整型 async updateUserInfo(name,sex,birthday,grade,school_id,entrance_year) { const data = { sex : sex, }; let header = { 'content-type': 'application/x-www-form-urlencoded' }; const result = await this.$zwwl.api.updateUserInfo(data,header); //网络请求成功 if(result.data!=null && result.code == 200){ } }, 关于@tap.stop.prevent 比如选择城市列表控件中,省,市,区三级tab。点击省列表item,请求该省的市数据,然后切换到该市的tab页面。同时,选择完成后,点击控件关闭城市列表弹窗 什么叫做事件冒泡:点击外面的时候,不会触发里面元素的事件;但是点击里面元素的时候,就会触发外面元素的事件,这就是事件冒泡!!具体可以看这篇博客 阻止事件冒泡时要在外层加一层标签,直接在需要使用的方法上加.stop无效 <view v-if="tabTitle.length > 0 && show" :class="[{'tabBlock__animation' :show},'tabBlock']" @tap.stop.prevent="onClickModule"> <!-- 省市区,以及确定按钮 --> <scroll-view scroll-x="true" scroll-with-animation :scroll-left="tabsScrollLeft" @scroll="scroll"> <view :class="'tab'" id="tab_list" > <view v-for="(item, index) in tabTitle" :key="index" :class="['tab__item',{'tab__item--active':currentIndex === index}]" :style="{color: (currentIndex === index ? `${itemColor}`: '')}" id="tab_item" @tap.stop.prevent="onSelect(index)"> <view class="tab__item-title"> {{item.title}} </view> </view> <view class="confirm" @tap.stop.prevent="onConfirm"> 确定 </view> <view class="tab__line" :style="{ background: lineColor, width: lineStyle.width, transform: lineStyle.transform,transitionDuration: lineStyle.transitionDuration}"></view> </view> </scroll-view> <!-- 地区数据 --> <scroll-view class="content-view" scroll-y="true"> <view class="item-view" v-for="(item,ind) in areaListData" :key="ind" @tap.stop.prevent="onAreaItemClick(ind)"> <view class="desc">{{item.name}}</view> <view class="cell-line"></view> </view> </scroll-view> </view> this作用域问题 第一种解决方案 解决办法就是在闭包之外先把this赋值给另一个变量 //可以发现这样操作就可以解决作用域问题 changeTitle3(){ //赋值 var me = this; uni.setStorage({ key: 'storage_key', data: 'hello', success: function () { me.title = "改变标题3"; console.log('changeTitle2------success'); } }); }, 第二种解决方案 使用箭头函数也可以解决该问题,思考一下这是为什么?但是不建议使用这种…… changeTitle4(){ uni.setStorage({ key: 'storage_key', data: 'hello', success:() => { this.title = "改变标题4"; console.log('changeTitle2------success'); } }); }, 04.关于交互问题 在省市区城市列表中 出现问题 当切换不同省,获取城市数组的顺序发生变更后,点击事件接收到的 index 索引并不会随着更新,还是数组顺序发生变更前的索引值。 解决方案 当页面需要同时存在两个或两个以上的v-for的时候,key的值就需要根据你最终应用的环境来正确设置。如果是适应多端平台的话,以下方法可以作为参考: 1、把一些需要v-for的部分做成组件,这样页面上就不存在多个 v-for 2、使用遍历的元素的某个字段值作为key,但是这个字段值必须是唯一的不重复的,如下:list.id等等 为何需要key 可以参考:演示v-for为什么要加key 使用 v-for 循环整数时和其他平台存在差异,如 v-for="(item, index) in array" 中,在H5平台 item 从 1 开始,其他平台 item 从 0 开始,可使用第二个参数 index 来保持一致。 <!-- 省市区,以及确定按钮 --> <scroll-view scroll-x="true" scroll-with-animation :scroll-left="tabsScrollLeft" @scroll="scroll"> <view :class="'tab'" id="tab_list" > <view v-for="(item, index) in tabTitle" :key="index" :class="['tab__item',{'tab__item--active':currentIndex === index}]" :style="{color: (currentIndex === index ? `${itemColor}`: '')}" id="tab_item" @tap.stop.prevent="onSelect(index)"> <view class="item-title">{{item.title}}</view> </view> </view> </scroll-view> <!-- 地区数据 --> <scroll-view class="content-view" scroll-y="true"> <view class="item-view" v-for="(item,ind) in areaListData" :key="ind" @tap.stop.prevent="onAreaItemClick(ind)"> <view class="desc">{{item.name}}</view> <view class="cell-line"></view> </view> </scroll-view> 06.关于回传数据 如何关闭当前页面,返回到上一页面 页面返回 调用uni.navigateBack、用户按左上角返回按钮、安卓用户点击物理back按键 第一种回传数据 采用uni.$emit()与uni.$on()的方式。$emit是触发事件,$on是接受事件,通过eventName匹配。这种方式有点类似Android中eventBus事件通知 uni.$emit(eventName,OBJECT) uni.$emit('update',{msg:'页面更新'}) uni.$on(eventName,callback) uni.$on('update',function(data){ console.log('监听到事件来自 update ,携带参数 msg 为:' + data.msg); }) 存在问题:需要接触监听,监听事件会执行多次 第二种回传数据 在b页面操作 var pages = getCurrentPages(); //上一个页面 var prevPage = pages[pages.length - 2]; prevPage.setData({ sx1:"参数1", sx2:"参数2", }) uni.navigateBack({ delta:1 }); 在a页面操作 onShow(object){ if(!!object){ console.log('vue onShow' + object) } }, 报错问题:"message": "prevPage.setData is not a function" 第三种回传数据 在b页面操作 var pages = getCurrentPages(); //上一个页面 var prevPage = pages[pages.length - 2]; var object={ sx1:"参数1", sx2:"参数2", } //重点$vm prevPage.$vm.otherFun(object); uni.navigateBack(); 在a页面操作 //这个方法写在methons中 otherFun(object){ if(!!object){ console.log(object) } } 报错问题:"message": "prevPage.$vm.otherFun is not a function", 目前如何回传数据 还没有找到好方案,请教同事说,先保存数据,关闭页面,然后在onShow方法获取 07.关于网络请求 网络请求指POST的坑 在学员中心,用户填完数据后,需要提交数据请求接口。使用到post请求,注意,一定需要添加请求header,否则无法上传数据 为何会出现这个错误 以 POST 方式进行网络请求时,如果不添加header头是无法进行正常的网络请求的,此时默认的请求方式content-type类型是application/json 解决方案 let header = { 'content-type': 'application/x-www-form-urlencoded' }; const result = await this.$zwwl.api.updateUserInfo(data,header); 参考文章:uni-app 网络请求指POST的坑 如果不添加header头 可以直接查看不添加header头,默认的content-type是:application/json;charset=UTF-8 添加header头,设置为'content-type': 'application/x-www-form-urlencoded' 这两种区别 application/x-www-form-urlencoded表示表单,上传参数的格式为key=value&key=value application/json代表参数以json字符串传递给后台 08.关于页面刷新 比如,在登陆页面,有未登陆,登陆,会员等多种状态view,用户执行完某个动作,改变了某些状态,需要重新刷新页面,以此来重新渲染页面。 第一种是用原始方法:location.reload();不过是强制刷新页面,会出现短暂的闪烁,用户体验效果不好。 第二种是用vue自带的路由跳转:this.$router.go(0);和第一种一样,强制刷新。 第三种使用到v-if,具体操作如下所示,只需要改变isShow的属性值即可刷新 <template> <view> <!-- v-if v-show 的区别:前者是否会在dom中被移除,后者 display:none --> <view v-show="isShow"> now you see me haha </view> </view> </template> <script> export default { data() { return { isShow: true, }; } } </script> <style> </style> 09.关于注意问题 组件内引入图片要使用绝对路径。使用这种/static/...是最好的 主页面的生命周期用onLoad代替created,onReady代替mounted。组件内使用原来的created与mounted 阻止事件冒泡时要在外层加一层标签,直接在需要使用的方法上加.stop无效 不要引入体积大的js,如果是超过500k,工具编译的时候会给提示 比如,在地区选择控件中,省,市,区是三个接口。避免滚动监听请求接口数据,当监听 scroll-view 的滚动事件时,视图层会频繁的向逻辑层发送数据 10.待解决和思考 关于页面关闭,返回上一页面,需要传递数据,具体该如何操作才有效? 长列表中如果每个item有一个加入购物车按钮,点击后数字+1,如何才能不刷新整个list?
目录介绍 01.先看一个案例 02.看一下解决方案 01.先看一个案例 代码如下所示 发现了点击按钮1可以更新title内容,但是点击按钮2却无法更新title内容。这个究竟是为什么呢? <template> <view class="container"> <text>{{title}}</text> <button type="default" @click="changeTitle1">改变标题内容按钮1</button> <button type="default" @click="changeTitle2">改变标题内容按钮2</button> </view> </template> <script> export default{ data(){ return{ title : "这个是标题", } }, methods:{ changeTitle1(){ this.title = "改变标题1"; }, //可以发现下面这个执行了success方法,但是调用this赋值却无法改变内容 changeTitle2(){ uni.setStorage({ key: 'storage_key', data: 'hello', success: function () { this.title = "改变标题2"; console.log('changeTitle2------success'); } }); }, } } </script> <style> .container{ display: flex; flex-flow: column; } </style> 为什么changeTitle2无法改变title内容 在changeTitle2方法的success方法中,该success方法指向闭包,所以this属于闭包,由此在success回调函数里是不能直接使用this.title的。 02.看一下解决方案 可以发现这样操作就可以解决作用域问题 第一种解决方案 解决办法就是在闭包之外先把this赋值给另一个变量 //可以发现这样操作就可以解决作用域问题 changeTitle3(){ //赋值 var me = this; uni.setStorage({ key: 'storage_key', data: 'hello', success: function () { me.title = "改变标题3"; console.log('changeTitle2------success'); } }); }, 第二种解决方案 使用箭头函数也可以解决该问题,思考一下这是为什么? changeTitle4(){ uni.setStorage({ key: 'storage_key', data: 'hello', success:() => { this.title = "改变标题4"; console.log('changeTitle2------success'); } }); },
liveData实现事件总线 目录介绍 01.EventBus使用原理 02.RxBus使用原理 03.为何使用liveData 04.LiveDataBus的组成 05.LiveDataBus原理图 06.简单的实现案例代码 07.遇到的问题和分析思路 08.使用反射解决遇到问题 09.使用postValue的bug 10.如何发送延迟事件消息 11.如何发送轮训延迟事件 12.避免类型转换异常问题 13.如何实现生命周期感知 00.事件开源库 事件总线开源库:https://github.com/yangchong211/YCLiveDataBus 01.EventBus使用原理 框架的核心思想,就是消息的发布和订阅,使用订阅者模式实现,其原理图大概如下所示,摘自网络。 发布和订阅之间的依赖关系,其原理图大概如下所示,摘自网络。 订阅/发布模式和观察者模式之间有着微弱的区别,个人觉得订阅/发布模式是观察者模式的一种增强版。两者区别如下所示,摘自网络。 具体使用可以看demo代码,demo开源地址 02.RxBus使用原理 RxBus不是一个库,而是一个文件,实现只有短短30行代码。RxBus本身不需要过多分析,它的强大完全来自于它基于的RxJava技术。 在RxJava中有个Subject类,它继承Observable类,同时实现了Observer接口,因此Subject可以同时担当订阅者和被订阅者的角色,我们使用Subject的子类PublishSubject来创建一个Subject对象(PublishSubject只有被订阅后才会把接收到的事件立刻发送给订阅者),在需要接收事件的地方,订阅该Subject对象,之后如果Subject对象接收到事件,则会发射给该订阅者,此时Subject对象充当被订阅者的角色。 完成了订阅,在需要发送事件的地方将事件发送给之前被订阅的Subject对象,则此时Subject对象作为订阅者接收事件,然后会立刻将事件转发给订阅该Subject对象的订阅者,以便订阅者处理相应事件,到这里就完成了事件的发送与处理。 最后就是取消订阅的操作了,RxJava中,订阅操作会返回一个Subscription对象,以便在合适的时机取消订阅,防止内存泄漏,如果一个类产生多个Subscription对象,我们可以用一个CompositeSubscription存储起来,以进行批量的取消订阅。 具体使用可以看demo代码,demo开源地址 03.为何使用liveData 为何使用liveData LiveData具有的这种可观察性和生命周期感知的能力,使其非常适合作为Android通信总线的基础构件。在一对多的场景中,发布消息事件后,订阅事件的页面只有在可见的时候才会处理事件逻辑。 使用者不用显示调用反注册方法。LiveData具有生命周期感知能力,所以LiveDataBus只需要调用注册回调方法,而不需要显示的调用反注册方法。这样带来的好处不仅可以编写更少的代码,而且可以完全杜绝其他通信总线类框架(如EventBus、RxBus)忘记调用反注册所带来的内存泄漏的风险。 该liveDataBus优势 1.该LiveDataBus的实现比较简单,支持发送普通事件,也支持发送粘性事件; 2.该LiveDataBus支持发送延迟事件消息,也可以用作轮训延迟事件(比如商城类项目某活动页面5秒钟刷一次接口数据),支持stop轮训操作 3.该LiveDataBus可以减小APK包的大小,由于LiveDataBus只依赖Android官方Android Architecture Components组件的LiveData; 4.该LiveDataBus具有生命周期感知,这个是一个很大的优势。不需要反注册,避免了内存泄漏等问题; 关于liveData深度解析,可以看我这篇博客:01.LiveData详细分析 04.LiveDataBus的组成 消息: 消息可以是任何的 Object,可以定义不同类型的消息,如 Boolean、String。也可以定义自定义类型的消息。 消息通道: LiveData 扮演了消息通道的角色,不同的消息通道用不同的名字区分,名字是 String 类型的,可以通过名字获取到一个 LiveData 消息通道。 消息总线: 消息总线通过单例实现,不同的消息通道存放在一个 HashMap 中。 订阅: 订阅者通过 getChannel() 获取消息通道,然后调用 observe() 订阅这个通道的消息。 发布: 发布者通过 getChannel() 获取消息通道,然后调用 setValue() 或者 postValue() 发布消息。 05.LiveDataBus原理图 为了方便理解,LiveDataBus原理图如下所示 订阅和注册的流程图 订阅注册原理图 为何用LiveDataBus替代EventBus和RxBus LiveDataBus的实现极其简单 LiveDataBus可以减小APK包的大小,由于LiveDataBus只依赖Android官方Android Architecture Components组件的LiveData。 LiveDataBus具有生命周期感知。 06.简单的实现案例代码 我这里先用最简单的代码实现liveDataBus,然后用一下,看一下会出现什么问题,代码如下所示: public final class LiveDataBus1 { private final Map<String, MutableLiveData<Object>> bus; private LiveDataBus1() { bus = new HashMap<>(); } private static class SingletonHolder { private static final LiveDataBus1 DATA_BUS = new LiveDataBus1(); } public static LiveDataBus1 get() { return SingletonHolder.DATA_BUS; } public <T> MutableLiveData<T> getChannel(String target, Class<T> type) { if (!bus.containsKey(target)) { bus.put(target, new MutableLiveData<>()); } return (MutableLiveData<T>) bus.get(target); } public MutableLiveData<Object> getChannel(String target) { return getChannel(target, Object.class); } } 那么如何发送消息和接收消息呢,注意两者的key需要保持一致,否则无法接收?具体代码如下所示: //发送消息 LiveDataBus1.get().getChannel("yc_bus").setValue(text); //接收消息 LiveDataBus1.get().getChannel("yc_bus", String.class) .observe(this, new Observer<String>() { @Override public void onChanged(@Nullable String newText) { // 更新数据 tvText.setText(newText); } }); 07.遇到的问题和分析思路 遇到的问题: 1.LiveData 一时使用一时爽,爽完了之后我们发现这个简易的 LiveDataBus 存在一个问题,就是订阅者会收到订阅之前发布的消息,类似于粘性消息。对于一个消息总线来说,这是不可接受的。 2.多次调用了 postValue() 方法,只有最后次调用的值会得到更新。也就是此方法是有可能会丢失事件! 7.1 先看第一个问题 然后看一下LiveData的订阅方法observe源码 看下面代码可知道,LiveData 内部会将传入参数包装成 wrapper ,然后存在一个 Map 中,最后通过 LifeCycle 组件添加观察者。 // 注释只能在主线程中调用该方法 @MainThread public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) { // 当前绑定的组件(activity or fragment)状态为DESTROYED的时候, 则会忽视当前的订阅请求 if (owner.getLifecycle().getCurrentState() == DESTROYED) { // ignore return; } // 转为带生命周期感知的观察者包装类 LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer); ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper); // 对应观察者只能与一个owner绑定,否则抛出异常 if (existing != null && !existing.isAttachedTo(owner)) { throw new IllegalArgumentException("Cannot add the same observer" + " with different lifecycles"); } if (existing != null) { return; } // lifecycle注册 owner.getLifecycle().addObserver(wrapper); } 紧接着,来看一下LiveData的更新数据方法 LiveData 更新数据方式有两个,一个是 setValue() 另一个是 postValue(),这两个方法的区别是,postValue() 在内部会抛到主线程去执行更新数据,因此适合在子线程中使用;而 setValue() 则是直接更新数据。 @MainThread protected void setValue(T value) { assertMainThread("setValue"); // 这里的 mVersion,它本问题关键,每次更新数据都会自增,默认值是 -1。 mVersion++; mData = value; dispatchingValue(null); } 跟进下 dispatchingValue() 方法,注意,这里需要重点看considerNotify代码: private void dispatchingValue(@Nullable ObserverWrapper initiator) { // mDispatchingValue的判断主要是为了解决并发调用dispatchingValue的情况 // 当对应数据的观察者在执行的过程中, 如有新的数据变更, 则不会再次通知到观察者。所以观察者内的执行不应进行耗时工作 if (mDispatchingValue) { mDispatchInvalidated = true; return; } mDispatchingValue = true; do { mDispatchInvalidated = false; if (initiator != null) { // 等下重点看这里的代码 considerNotify(initiator); initiator = null; } else { for (Iterator<Map.Entry<Observer<T>, ObserverWrapper>> iterator = mObservers.iteratorWithAdditions(); iterator.hasNext(); ) { // 等下重点看这里的代码 considerNotify(iterator.next().getValue()); if (mDispatchInvalidated) { break; } } } } while (mDispatchInvalidated); mDispatchingValue = false; } 然后看一下considerNotify() 方法做了什么,代码如下所示,这里有道词典翻译下注释: private void considerNotify(ObserverWrapper observer) { if (!observer.mActive) { return; } // 检查最新的状态b4调度。也许它改变了状态,但我们还没有得到事件。 // 我们还是先检查观察者。活动,以保持它作为活动的入口。 // 因此,即使观察者移动到一个活动状态,如果我们没有收到那个事件,我们最好不要通知一个更可预测的通知顺序。 if (!observer.shouldBeActive()) { observer.activeStateChanged(false); return; } if (observer.mLastVersion >= mVersion) { return; } observer.mLastVersion = mVersion; //noinspection unchecked observer.mObserver.onChanged((T) mData); } 为何订阅者会马上收到订阅之前发布的最新消息? 如果 ObserverWrapper 的 mLastVersion 小于 LiveData 的 mVersion,那么就会执行的 onChange() 方法去通知观察者数据已更新。而 ObserverWrapper.mLastVersion 的默认值是 -1, LiveData 只要更新过数据,mVersion 就肯定会大于 -1,所以订阅者会马上收到订阅之前发布的最新消息!! 7.2 然后看一下第二个问题 首先看一下postValue源代码,如下所示: 看代码注释中说,如果在多线程中同一个时刻,多次调用了 postValue() 方法,只有最后次调用的值会得到更新。也就是此方法是有可能会丢失事件! postValue 只是把传进来的数据先存到 mPendingData,ArchTaskExecutor.getInstance()获取的是一个单利对象。然后往主线程抛一个 Runnable,在这个 Runnable 里面再调用 setValue 来把存起来的值真正设置上去,并回调观察者们。而如果在这个 Runnable 执行前多次 postValue,其实只是改变暂存的值 mPendingData,并不会再次抛另一个 Runnable。 protected void postValue(T value) { boolean postTask; synchronized (mDataLock) { postTask = mPendingData == NOT_SET; mPendingData = value; } if (!postTask) { return; } ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable); } private final Runnable mPostValueRunnable = new Runnable() { @Override public void run() { Object newValue; synchronized (mDataLock) { newValue = mPendingData; mPendingData = NOT_SET; } //noinspection unchecked setValue((T) newValue); } }; 08.使用反射解决遇到问题 根据之前的分析,只需要在注册一个新的订阅者的时候把Wrapper的version设置成跟LiveData的version一致即可。 能不能从Map容器mObservers中取到LifecycleBoundObserver,然后再更改version呢?答案是肯定的,通过查看SafeIterableMap的源码我们发现有一个protected的get方法。因此,在调用observe的时候,我们可以通过反射拿到LifecycleBoundObserver,再把LifecycleBoundObserver的version设置成和LiveData一致即可。 @Override public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) { super.observe(owner, observer); hook(observer); } private void hook(@NonNull Observer<T> observer) { try { Class<LiveData> classLiveData = LiveData.class; Field fieldObservers = classLiveData.getDeclaredField("mObservers"); fieldObservers.setAccessible(true); Object objectObservers = fieldObservers.get(this); Class<?> classObservers = objectObservers.getClass(); Method methodGet = classObservers.getDeclaredMethod("get", Object.class); methodGet.setAccessible(true); Object objectWrapperEntry = methodGet.invoke(objectObservers, observer); Object objectWrapper = null; if (objectWrapperEntry instanceof Map.Entry) { objectWrapper = ((Map.Entry) objectWrapperEntry).getValue(); } if (objectWrapper != null) { Class<?> classObserverWrapper = objectWrapper.getClass().getSuperclass(); Field fieldLastVersion = null; if (classObserverWrapper != null) { fieldLastVersion = classObserverWrapper.getDeclaredField("mLastVersion"); fieldLastVersion.setAccessible(true); Field fieldVersion = classLiveData.getDeclaredField("mVersion"); fieldVersion.setAccessible(true); Object objectVersion = fieldVersion.get(this); fieldLastVersion.set(objectWrapper, objectVersion); } } } catch (Exception e){ e.printStackTrace(); } } 同时还需要注意,在实现MutableLiveData自定义类BusMutableLiveData中,需要重写这几个方法。代码如下所示: /** * 在给定的观察者的生命周期内将给定的观察者添加到观察者列表所有者。 * 事件是在主线程上分派的。如果LiveData已经有数据集合,它将被传递给观察者。 * @param owner owner * @param observer observer */ public void observeSticky(@NonNull LifecycleOwner owner, @NonNull Observer<T> observer) { super.observe(owner, observer); } /** * 将给定的观察者添加到观察者列表中。这个调用类似于{@link LiveData#observe(LifecycleOwner, Observer)} * 和一个LifecycleOwner, which总是积极的。这意味着给定的观察者将接收所有事件,并且永远不会 被自动删除。 * 您应该手动调用{@link #removeObserver(Observer)}来停止 观察这LiveData。 * @param observer observer */ public void observeStickyForever(@NonNull Observer<T> observer) { super.observeForever(observer); } 09.使用postValue的bug 9.1 模拟通过发送多个postValue消息出现丢失问题 首先看看MutableLiveData源代码,如下所示,这里重点展示测试数据案例 public void postValue(T value) { super.postValue(value); } 然后使用for循环,使用postValue发送100条消息事件,代码如下所示: public void postValueCountTest() { sendCount = 100; receiveCount = 0; ExecutorService threadPool = Executors.newFixedThreadPool(2); for (int i = 0; i < sendCount; i++) { threadPool.execute(new Runnable() { @Override public void run() { LiveDataBus2.get().getChannel(Constant.LIVE_BUS3).postValue("test_1_data"+sendCount); } }); } new Handler().postDelayed(new Runnable() { @Override public void run() { BusLogUtils.d("sendCount: " + sendCount + " | receiveCount: " + receiveCount); Toast.makeText(ThirdActivity4.this, "sendCount: " + sendCount + " | receiveCount: " + receiveCount, Toast.LENGTH_LONG).show(); } }, 1000); } //接收消息 LiveDataBus2.get() .getChannel(Constant.LIVE_BUS3, String.class) .observe(this, new Observer<String>() { @Override public void onChanged(@Nullable String s) { receiveCount++; BusLogUtils.d("接收消息--ThirdActivity4------yc_bus---1-"+s+"----"+receiveCount); } }); 然后看一下打印日志,是不是发现了什么问题?发现根本没有100条数据…… 2020-03-03 10:25:51.397 4745-4745/com.ycbjie.yclivedatabus D/BusLogUtils: 接收消息--ThirdActivity4------yc_bus---1-test_1_data100----1 2020-03-03 10:25:51.397 4745-4745/com.ycbjie.yclivedatabus D/BusLogUtils: 接收消息--ThirdActivity4------yc_bus---1-test_1_data100----2 2020-03-03 10:25:51.397 4745-4745/com.ycbjie.yclivedatabus D/BusLogUtils: 接收消息--ThirdActivity4------yc_bus---1-test_1_data100----3 2020-03-03 10:25:51.403 4745-4745/com.ycbjie.yclivedatabus D/BusLogUtils: 接收消息--ThirdActivity4------yc_bus---1-test_1_data100----4 9.2 修改后使用handler处理postValue消息 既然post是在子线程中发送消息事件,那么可不可以使用handler将它放到主线程中处理事件了,是可以的,代码如下所示 /** * 子线程发送事件 * @param value value */ @Override public void postValue(T value) { //注意,去掉super方法, //super.postValue(value); mainHandler.post(new PostValueTask(value)); } private BusWeakHandler mainHandler = new BusWeakHandler(Looper.getMainLooper()); private class PostValueTask implements Runnable { private T newValue; public PostValueTask(@NonNull T newValue) { this.newValue = newValue; } @Override public void run() { setValue(newValue); } } 然后再次使用for循环,发送100条消息事件,查看日志。发现就会刚好有100条数据。代码这里就不展示了,跟上面测试代码类似。 10.如何发送延迟事件消息 可以知道,通过postValue可以在子线程发送消息,那么发送延迟消息也十分简单,代码如下所示: /** * 子线程发送事件 * @param value value */ @Override public void postValue(T value) { //注意,去掉super方法, //super.postValue(value); mainHandler.post(new PostValueTask(value)); } /** * 发送延迟事件 * @param value value * @param delay 延迟时间 */ @Override public void postValueDelay(T value, long delay) { mainHandler.postDelayed(new PostValueTask(value) , delay); //mainHandler.postAtTime(new PostValueTask(value) , delay); } 测试用例,延迟5秒钟发送事件,代码如下所示。具体可以看demo钟的案例! LiveDataBus.get().with(Constant.LIVE_BUS4).postValueDelay("test_4_data",5000); 11.如何发送轮训延迟事件 轮训延迟事件,比如有的页面需要实现,每间隔5秒钟就刷新一次页面数据,常常用于活动页面。在购物商城这类需求很常见 @Override public void postValueInterval(final T value, final long interval) { mainHandler.postDelayed(new Runnable() { @Override public void run() { setValue(value); mainHandler.postDelayed(this,interval); } },interval); } 测试用例,轮训延迟3秒钟发送事件,代码如下所示。具体可以看demo钟的案例! LiveDataBus.get().with(Constant.LIVE_BUS5).postValueInterval("test_5_data",3000); 这里遇到了一个问题,假如有多个页面有这种轮训发送事件的需求,显然这个是实现不了的。那么可不可以把每个轮训runnable记录一个名称区别开来代码更更改如下 /** * 发送延迟事件,间隔轮训 * @param value value * @param interval 间隔 */ @Deprecated @Override public void postValueInterval(final T value, final long interval,@NonNull String taskName) { if(taskName.isEmpty()){ return; } IntervalValueTask intervalTask = new IntervalValueTask(value,interval); intervalTasks.put(taskName,intervalTask); mainHandler.postDelayed(intervalTask,interval); } private class IntervalValueTask implements Runnable { private T newValue; private long interval; public IntervalValueTask(T newValue, long interval) { this.newValue = newValue; this.interval = interval; } @Override public void run() { setValue(newValue); mainHandler.postDelayed(this,interval); } } 轮训总不可以一直持续下去吧,这个时候可以添加一个手动关闭轮训的方法。代码如下所示: /** * 停止轮训间隔发送事件 */ @Deprecated @Override public void stopPostInterval(@NonNull String taskName) { IntervalValueTask intervalTask = intervalTasks.get(taskName); if(intervalTask!= null){ //移除callback mainHandler.removeCallbacks(intervalTask); intervalTasks.remove(taskName); } } 12.避免类型转换异常问题 代码如下所示 public class SafeCastObserver<T> implements Observer<T> { @NonNull private final Observer<T> observer; public SafeCastObserver(@NonNull Observer<T> observer) { this.observer = observer; } @Override public void onChanged(@Nullable T t) { //捕获异常,避免出现异常之后,收不到后续的消息事件 try { //注意为了避免转换出现的异常,try-catch捕获 observer.onChanged(t); } catch (ClassCastException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } } } 13.如何实现生命周期感知 生命周期感知能力就是当在Android平台的LifecycleOwner(如Activity)中使用的时候,只需要订阅消息,而不需要取消订阅消息。LifecycleOwner的生命周期结束的时候,会自动取消订阅。这带来了两个好处: 可以在任何位置订阅消息,而不是必须在onCreate方法中订阅 避免了忘记取消订阅引起的内存泄漏 具体已经对lifecycle源码作出了分析,具体可以看我上一篇的博客。Lifecycle详细分析 参考内容 https://juejin.im/post/5dce5b16f265da0ba5279b11 https://mp.weixin.qq.com/s/sHXzbGZjZMsem6VMbrSP7A https://juejin.im/post/5d84a273e51d45620923892b https://github.com/JeremyLiao/SmartEventBus https://developer.android.com/topic/libraries/architecture/lifecycle 事件总线开源库:https://github.com/yangchong211/YCLiveDataBus
Lifecycle源码分析 目录介绍 01.Lifecycle的作用是什么 02.Lifecycle的简单使用 03.Lifecycle的使用场景 04.如何实现生命周期感知 05.注解方法如何被调用 06.addObserver调用分析 07.知识点梳理和总结一下 00.使用AAC实现bus事件总线 利用LiveData实现事件总线,替代EventBus。充分利用了生命周期感知功能,可以在activities, fragments, 或者 services生命周期是活跃状态时更新这些组件。支持发送普通事件,也可以发送粘性事件;还可以发送延迟消息,以及轮训延迟消息等等。 https://github.com/yangchong211/YCLiveDataBus 01.Lifecycle的作用是什么 Lifecycle 是一个专门用来处理生命周期的库,它能够帮助我们将 Activity、Fragment 的生命周期处理与业务逻辑处理进行完全解耦,让我们能够更加专注于业务;通过解耦让 Activity、Fragment 的代码更加可读可维护。 02.Lifecycle的简单使用 直接看一下下面的案例,用法十分简单,代码如下 可以通过 getLifecycle() 方法拿到 Lifecycle, 并添加 Observer 来实现对 Activity 生命周期的监听。 public class FourActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); BusLogUtils.d("------AppCompatActivity onCreate() called"); testLifecycle(); } @Override protected void onResume() { super.onResume(); BusLogUtils.d("------AppCompatActivity onResume() called"); } @Override protected void onDestroy() { super.onDestroy(); BusLogUtils.d("------AppCompatActivity onDestroy() called"); } private void testLifecycle() { getLifecycle().addObserver(new LifecycleObserver() { @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) void onCreate(){ BusLogUtils.d("------LifecycleObserver onCreate() called"); } @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) void onResume(){ BusLogUtils.d("------LifecycleObserver onResume() called"); } @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) void onDestroy(){ BusLogUtils.d("------LifecycleObserver onDestroy() called"); } }); } } 然后打印日志记录如下所示 可以发现Lifecycle是可以监听activity的生命周期的。 在activity创建的时候,activity中生命周期onCreate方法优先LifecycleObserver中onCreate方法先执行;关闭的时候相反! //打开页面 2020-03-06 09:44:09.522 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------AppCompatActivity onCreate() called 2020-03-06 09:44:09.545 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------LifecycleObserver onCreate() called 2020-03-06 09:44:09.551 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------AppCompatActivity onResume() called 2020-03-06 09:44:09.552 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------LifecycleObserver onResume() called //关闭页面 2020-03-06 09:44:14.265 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------LifecycleObserver onStop() called 2020-03-06 09:44:14.265 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------AppCompatActivity onStop() called 2020-03-06 09:44:14.266 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------LifecycleObserver onDestroy() called 2020-03-06 09:44:14.266 11647-11647/com.ycbjie.yclivedatabus D/BusLogUtils: ------AppCompatActivity onDestroy() called 03.Lifecycle的使用场景 Lifecycle 的应用场景非常广泛,我们可以利用 Lifecycle 的机制来帮助我们将一切跟生命周期有关的业务逻辑全都剥离出去,进行完全解耦。 比如视频的暂停与播放, Handler 的消息移除, 网络请求的取消操作, Presenter 的 attach&detach View 暂停和恢复动画绘制 并且可以以一个更加优雅的方式实现,还我们一个更加干净可读的 Activity & Fragment。 关于网络请求的取消操作 Retrofit 结合 Lifecycle, 将 Http 生命周期管理到极致 停止和开启视频缓冲 使用支持生命周期的组件尽快开始视频缓冲,但是将播放推迟到应用程序完全启动。 还可以使用可识别生命周期的组件在应用程序销毁时终止缓冲。 启动和停止网络连接 使用可感知生命周期的组件可以在应用程序处于前台状态时实时更新(流式传输)网络数据,并在应用程序进入后台时自动暂停。 暂停和恢复动画绘制 当应用程序在后台运行时,使用生命周期感知组件处理暂停动画绘制,并在应用程序在前台运行后恢复绘制。 04.如何实现生命周期感知 看到上面的简单案例,可以发现使用了注解@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)标注的方法,既可以执行生命周期的监听。 那么追踪到Lifecycle.Event类,看看还有哪里使用到了该注解,截图如下所示,这里我们就先看一下ReportFragment类,看上去应该跟Fragment有关系! 4.1 生命周期事件与状态 上面案例中使用到了注解,那么它究竟有那些状态呢? Lifecycle是一个抽象类,里面主要有两个功能,一个是Event生命周期,一个是State状态。 Lifecycle.Event表示生命周期的状态,与 Activity 生命周期类似。 Lifecycle.State表示当前组件的生命周期状态, public abstract class Lifecycle { @MainThread public abstract void addObserver(@NonNull LifecycleObserver observer); @MainThread public abstract void removeObserver(@NonNull LifecycleObserver observer); @MainThread @NonNull public abstract State getCurrentState(); @SuppressWarnings("WeakerAccess") public enum Event { ON_CREATE, ON_START, ON_RESUME, ON_PAUSE, ON_STOP, ON_DESTROY, ON_ANY } @SuppressWarnings("WeakerAccess") public enum State { DESTROYED, INITIALIZED, CREATED, STARTED, RESUMED; public boolean isAtLeast(@NonNull State state) { return compareTo(state) >= 0; } } } Event 与 State 的关系(摘自网络) 4.2 ReportFragment类分析 源码如下所示,这里只是摘取了部分和生命周期有关的源代码。 重写了生命周期回调的方法,可以看到生命周期方法中调用了dispatch(Lifecycle.Event.XXX),是这个 ReportFragment 在发挥作用。 Lifecycle 利用了 Fragment 来实现监听生命周期,并在最终利用 dispatch 的方法来分发生命周期事件。 在Fragment生命周期发生变化时调用dispatch方法来分发生命周期,在里面调用了LifecycleRegistry的handleLifecycleEvent方法。 public class ReportFragment extends Fragment { //这个方法很关键,搜索一下那些地方用到了这个方法 public static void injectIfNeededIn(Activity activity) { // ProcessLifecycleOwner should always correctly work and some activities may not extend // FragmentActivity from support lib, so we use framework fragments for activities android.app.FragmentManager manager = activity.getFragmentManager(); if (manager.findFragmentByTag(REPORT_FRAGMENT_TAG) == null) { //把Fragment加入到Activity中 manager.beginTransaction().add(new ReportFragment(), REPORT_FRAGMENT_TAG).commit(); // Hopefully, we are the first to make a transaction. manager.executePendingTransactions(); } } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); dispatchCreate(mProcessListener); //分发状态 dispatch(Lifecycle.Event.ON_CREATE); } @Override public void onStart() { super.onStart(); dispatchStart(mProcessListener); //分发状态 dispatch(Lifecycle.Event.ON_START); } @Override public void onResume() { super.onResume(); dispatchResume(mProcessListener); //分发状态 dispatch(Lifecycle.Event.ON_RESUME); } @Override public void onPause() { super.onPause(); //分发状态 dispatch(Lifecycle.Event.ON_PAUSE); } @Override public void onStop() { //分发状态 super.onStop(); dispatch(Lifecycle.Event.ON_STOP); } @Override public void onDestroy() { super.onDestroy(); //分发状态 dispatch(Lifecycle.Event.ON_DESTROY); // just want to be sure that we won't leak reference to an activity mProcessListener = null; } //分发生命周期事件 private void dispatch(Lifecycle.Event event) { // 获取宿主activity Activity activity = getActivity(); if (activity instanceof LifecycleRegistryOwner) { //后面在分析handleLifecycleEvent方法源码 ((LifecycleRegistryOwner) activity).getLifecycle().handleLifecycleEvent(event); return; } if (activity instanceof LifecycleOwner) { Lifecycle lifecycle = ((LifecycleOwner) activity).getLifecycle(); if (lifecycle instanceof LifecycleRegistry) { ////后面在分析handleLifecycleEvent方法源码 ((LifecycleRegistry) lifecycle).handleLifecycleEvent(event); } } } //... } 然后再来看一下哪里用到了这个ReportFragment类,具体追踪到LifecycleDispatcher中的DispatcherActivityCallback内部类中的onActivityCreated方法,源码如下所示 class LifecycleDispatcher { static void init(Context context) { if (sInitialized.getAndSet(true)) { return; } ((Application) context.getApplicationContext()) .registerActivityLifecycleCallbacks(new DispatcherActivityCallback()); } @SuppressWarnings("WeakerAccess") @VisibleForTesting static class DispatcherActivityCallback extends EmptyActivityLifecycleCallbacks { private final FragmentCallback mFragmentCallback; DispatcherActivityCallback() { mFragmentCallback = new FragmentCallback(); } @Override public void onActivityCreated(Activity activity, Bundle savedInstanceState) { if (activity instanceof FragmentActivity) { ((FragmentActivity) activity).getSupportFragmentManager() .registerFragmentLifecycleCallbacks(mFragmentCallback, true); } ReportFragment.injectIfNeededIn(activity); } } } 接着看一下handleLifecycleEvent(event)源码代码,可以发现根据Event状态来获取State状态,然后分发状态。后续还会分析到…… public void handleLifecycleEvent(@NonNull Lifecycle.Event event) { State next = getStateAfter(event); moveToState(next); } 4.3 ComponentActivity类分析 fragment需要依赖宿主activity。通过搜索ReportFragment.injectIfNeededIn调用地方,发现 ComponentActivity 调用了该方法。(API 28 以下的版本是 SupportActivity ) 内部创建了一个 LifecycleRegistry 成员对象,并且该ComponentActivity类实现了 LifecycleOwner 。 在 onCreate 方法里 调用了 ReportFragment.injectIfNeededIn(this); 注入了 ReportFragment。通过getLifecycle可以获取mLifecycleRegistry对象! Lifecycle是一个抽象类,LifecycleRegistry是它的实现子类,主要是管理Observer, @RestrictTo(LIBRARY_GROUP) public class ComponentActivity extends Activity implements LifecycleOwner { private LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); @Override @SuppressWarnings("RestrictedApi") protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ReportFragment.injectIfNeededIn(this); } @CallSuper @Override protected void onSaveInstanceState(Bundle outState) { mLifecycleRegistry.markState(Lifecycle.State.CREATED); super.onSaveInstanceState(outState); } @Override public Lifecycle getLifecycle() { return mLifecycleRegistry; } } public class LifecycleRegistry extends Lifecycle {} public abstract class Lifecycle {} 05.注解方法如何被调用 OnLifecycleEvent 注解: 看到有 RetentionPolicy.RUNTIME 修饰,表示运行时注解,在运行时通过反射去识别的注解。 运行时注解一般和反射机制配合使用,相比编译时注解性能比较低,但灵活性好,实现起来比较简单。 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface OnLifecycleEvent { Lifecycle.Event value(); } 之前在了解完生命周期监听的原理的同时,我们也看到了生命周期事件的接收者 LifecycleRegistry ,是它的 handleLifecycleEvent() 接收了事件,我们继续追踪。 public void handleLifecycleEvent(Lifecycle.Event event) { mState = getStateAfter(event); if (mHandlingEvent || mAddingObserverCounter != 0) { mNewEventOccurred = true; // we will figure out what to do on upper level. return; } mHandlingEvent = true; sync(); mHandlingEvent = false; } 其实从方法注释就能看出来了,就是它处理了状态并通知了 observer 。看下 getStateAfter() 方法: getStateAfter() 这个方法根据当前 Event 获取对应的 State ,细看其实就是 【2.3.3】中那个图的代码实现。 static State getStateAfter(Event event) { switch (event) { case ON_CREATE: case ON_STOP: return CREATED; case ON_START: case ON_PAUSE: return STARTED; case ON_RESUME: return RESUMED; case ON_DESTROY: return DESTROYED; case ON_ANY: break; } throw new IllegalArgumentException("Unexpected event value " + event); } 接下去看 sync() 方法: private void sync() { while (!isSynced()) { mNewEventOccurred = false; // no need to check eldest for nullability, because isSynced does it for us. if (mState.compareTo(mObserverMap.eldest().getValue().mState) < 0) { backwardPass(); } Entry<LifecycleObserver, ObserverWithState> newest = mObserverMap.newest(); if (!mNewEventOccurred && newest != null && mState.compareTo(newest.getValue().mState) > 0) { forwardPass(); } } mNewEventOccurred = false; } sync 方法里对比了当前 mState 以及上一个 State ,看是应该前移还是后退,这个对应了生命周期的前进跟后退,打个比方就是从 onResume -> onPause (forwardPass),onPause -> onResume (backwardPass),拿 backwardPass() 举例吧。(forwardPass方法处理类似) private void backwardPass(LifecycleOwner lifecycleOwner) { Iterator<Entry<LifecycleObserver, ObserverWithState>> descendingIterator = mObserverMap.descendingIterator(); while (descendingIterator.hasNext() && !mNewEventOccurred) { Entry<LifecycleObserver, ObserverWithState> entry = descendingIterator.next(); ObserverWithState observer = entry.getValue(); while ((observer.mState.compareTo(mState) > 0 && !mNewEventOccurred && mObserverMap.contains(entry.getKey()))) { //调用 downEvent 获取更前面的 Event Event event = downEvent(observer.mState); pushParentState(getStateAfter(event)); //分发 Event observer.dispatchEvent(lifecycleOwner, event); popParentState(); } } } private static Event downEvent(State state) { switch (state) { case INITIALIZED: throw new IllegalArgumentException(); case CREATED: return ON_DESTROY; case STARTED: return ON_STOP; case RESUMED: return ON_PAUSE; case DESTROYED: throw new IllegalArgumentException(); } throw new IllegalArgumentException("Unexpected state value " + state); } 通过源码可以看到, backwardPass() 方法调用 downEvent 获取往回退的目标 Event。可能比较抽象,举个例子,在 onResume 的状态,我们按了 home,这个时候就是 RESUMED 的状态变到 STARTED 的状态,对应的要发送的 Event 是 ON_PAUSE,这个就是 backwardPass() 的逻辑了。如果前面的代码都是引子的话,最终看到了一丝分发的痕迹了—— observer.dispatchEvent(lifecycleOwner, event)。 static class ObserverWithState { State mState; GenericLifecycleObserver mLifecycleObserver; ObserverWithState(LifecycleObserver observer, State initialState) { mLifecycleObserver = Lifecycling.getCallback(observer); mState = initialState; } void dispatchEvent(LifecycleOwner owner, Event event) { State newState = getStateAfter(event); mState = min(mState, newState); //这里 mLifecycleObserver.onStateChanged(owner, event); mState = newState; } } 可以看到最后调用了 GenericLifecycleObserver.onStateChanged() 方法,再跟。 这个类的代码比较多,不过也不复杂。可以看到最后代码走到了invokeCallback() ,通过反射调用了方法。 而这个方法是 createInfo() 方法中反射遍历我们注册的 Observer 的方法找到的被 OnLifecycleEvent 注解修饰的方法,并且按 Event 类型存储到了 info.mEventToHandlers 里。 在 Observer 用注解修饰的方法,会被通过反射的方式获取,并保存下来,然后在生命周期发生改变的时候再找到对应 Event 的方法,通过反射来调用方法。 class ReflectiveGenericLifecycleObserver implements GenericLifecycleObserver { //mWrapped 是 我们的 Observer private final Object mWrapped; //反射 mWrapped 获取被注解了的方法 private final CallbackInfo mInfo; @SuppressWarnings("WeakerAccess") static final Map<Class, CallbackInfo> sInfoCache = new HashMap<>(); ReflectiveGenericLifecycleObserver(Object wrapped) { mWrapped = wrapped; mInfo = getInfo(mWrapped.getClass()); } @Override public void onStateChanged(LifecycleOwner source, Event event) { invokeCallbacks(mInfo, source, event); } private void invokeCallbacks(CallbackInfo info, LifecycleOwner source, Event event) { invokeMethodsForEvent(info.mEventToHandlers.get(event), source, event); invokeMethodsForEvent(info.mEventToHandlers.get(Event.ON_ANY), source, event); } private void invokeMethodsForEvent(List<MethodReference> handlers, LifecycleOwner source, Event event) { if (handlers != null) { for (int i = handlers.size() - 1; i >= 0; i--) { MethodReference reference = handlers.get(i); invokeCallback(reference, source, event); } } } //最后走到 invokeCallback 这里 private void invokeCallback(MethodReference reference, LifecycleOwner source, Event event) { //noinspection TryWithIdenticalCatches try { switch (reference.mCallType) { case CALL_TYPE_NO_ARG: reference.mMethod.invoke(mWrapped); break; case CALL_TYPE_PROVIDER: reference.mMethod.invoke(mWrapped, source); break; case CALL_TYPE_PROVIDER_WITH_EVENT: reference.mMethod.invoke(mWrapped, source, event); break; } } catch (InvocationTargetException e) { throw new RuntimeException("Failed to call observer method", e.getCause()); } catch (IllegalAccessException e) { throw new RuntimeException(e); } } private static CallbackInfo getInfo(Class klass) { CallbackInfo existing = sInfoCache.get(klass); if (existing != null) { return existing; } existing = createInfo(klass); return existing; } //通过反射获取 method 信息 private static CallbackInfo createInfo(Class klass) { //... Method[] methods = klass.getDeclaredMethods(); Class[] interfaces = klass.getInterfaces(); for (Class intrfc : interfaces) { for (Entry<MethodReference, Event> entry : getInfo(intrfc).mHandlerToEvent.entrySet()) { verifyAndPutHandler(handlerToEvent, entry.getKey(), entry.getValue(), klass); } } for (Method method : methods) { OnLifecycleEvent annotation = method.getAnnotation(OnLifecycleEvent.class); if (annotation == null) { continue; } Class<?>[] params = method.getParameterTypes(); int callType = CALL_TYPE_NO_ARG; if (params.length > 0) { callType = CALL_TYPE_PROVIDER; if (!params[0].isAssignableFrom(LifecycleOwner.class)) { throw new IllegalArgumentException( "invalid parameter type. Must be one and instanceof LifecycleOwner"); } } Event event = annotation.value(); //... MethodReference methodReference = new MethodReference(callType, method); verifyAndPutHandler(handlerToEvent, methodReference, event, klass); } CallbackInfo info = new CallbackInfo(handlerToEvent); sInfoCache.put(klass, info); return info; } @SuppressWarnings("WeakerAccess") static class CallbackInfo { final Map<Event, List<MethodReference>> mEventToHandlers; final Map<MethodReference, Event> mHandlerToEvent; CallbackInfo(Map<MethodReference, Event> handlerToEvent) { //... } } static class MethodReference { final int mCallType; final Method mMethod; MethodReference(int callType, Method method) { mCallType = callType; mMethod = method; mMethod.setAccessible(true); } } private static final int CALL_TYPE_NO_ARG = 0; private static final int CALL_TYPE_PROVIDER = 1; private static final int CALL_TYPE_PROVIDER_WITH_EVENT = 2; } 06.addObserver调用分析 看一下Lifecycle中addObserver方法,发现它是一个抽象方法,那么就去找它的实现类,这里先来看一下LifecycleRegistry类中的addObserver方法实现代码 @Override public void addObserver(@NonNull LifecycleObserver observer) { State initialState = mState == DESTROYED ? DESTROYED : INITIALIZED; //构造ObserverWithState ObserverWithState statefulObserver = new ObserverWithState(observer, initialState); //将observer对象和statefulObserver对象添加到FastSafeIterableMap数据结构中 ObserverWithState previous = mObserverMap.putIfAbsent(observer, statefulObserver); if (previous != null) { return; } LifecycleOwner lifecycleOwner = mLifecycleOwner.get(); if (lifecycleOwner == null) { // 它是null,我们应该被摧毁。快速回退 return; } boolean isReentrance = mAddingObserverCounter != 0 || mHandlingEvent; State targetState = calculateTargetState(observer); mAddingObserverCounter++; while ((statefulObserver.mState.compareTo(targetState) < 0 && mObserverMap.contains(observer))) { pushParentState(statefulObserver.mState); statefulObserver.dispatchEvent(lifecycleOwner, upEvent(statefulObserver.mState)); popParentState(); // mState / subling may have been changed recalculate targetState = calculateTargetState(observer); } if (!isReentrance) { // we do sync only on the top level. sync(); } mAddingObserverCounter--; } 然后看一下ObserverWithState类,追溯代码到Lifecycling.getCallback(observer),看看里面做了什么 static class ObserverWithState { State mState; GenericLifecycleObserver mLifecycleObserver; ObserverWithState(LifecycleObserver observer, State initialState) { mLifecycleObserver = Lifecycling.getCallback(observer); mState = initialState; } void dispatchEvent(LifecycleOwner owner, Event event) { State newState = getStateAfter(event); mState = min(mState, newState); mLifecycleObserver.onStateChanged(owner, event); mState = newState; } } 接着来看看Lifecycling类中getCallback方法 判断该Observer是否是GenericLifecycleObserver,是的话返回本身;如果是FullLifecycleObserver,则直接创建一个FullLifecycleObserverAdapter对象 判断是否包含注解处理器 查找是否包含“类名__LifecycleAdapter”的类 包含并且有OnLifecycleEvent注解则返回SingleGeneratedAdapterObserver/CompositeGeneratedAdaptersObserver 如果以上提交都不满足就通过反射调用回调方法 @NonNull static GenericLifecycleObserver getCallback(Object object) { if (object instanceof FullLifecycleObserver) { return new FullLifecycleObserverAdapter((FullLifecycleObserver) object); } if (object instanceof GenericLifecycleObserver) { return (GenericLifecycleObserver) object; } //获取传入对象object的Class对象 final Class<?> klass = object.getClass(); //获取类型是否包含注解处理器 int type = getObserverConstructorType(klass); if (type == GENERATED_CALLBACK) { ////这里是包含注解处理器 返回SingleGeneratedAdapterObserver 或者CompositeGeneratedAdaptersObserver List<Constructor<? extends GeneratedAdapter>> constructors = sClassToAdapters.get(klass); if (constructors.size() == 1) { GeneratedAdapter generatedAdapter = createGeneratedAdapter( constructors.get(0), object); return new SingleGeneratedAdapterObserver(generatedAdapter); } GeneratedAdapter[] adapters = new GeneratedAdapter[constructors.size()]; for (int i = 0; i < constructors.size(); i++) { adapters[i] = createGeneratedAdapter(constructors.get(i), object); } return new CompositeGeneratedAdaptersObserver(adapters); } ///通过反射调用方法 return new ReflectiveGenericLifecycleObserver(object); } 然后查看一下SingleGeneratedAdapterObserver类 通过ObserverWithState#dispatchEvent方法最后调用的实际是SingleGeneratedAdapterObserver里面的onStateChanged方法 在SingleGeneratedAdapterObserver里面调用了Adapter的callMethods方法 这个是 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class SingleGeneratedAdapterObserver implements GenericLifecycleObserver { private final GeneratedAdapter mGeneratedAdapter; SingleGeneratedAdapterObserver(GeneratedAdapter generatedAdapter) { mGeneratedAdapter = generatedAdapter; } @Override public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) { mGeneratedAdapter.callMethods(source, event, false, null); mGeneratedAdapter.callMethods(source, event, true, null); } } 然后看一下CompositeGeneratedAdaptersObserver类 通过ObserverWithState#dispatchEvent方法最后调用的实际是CompositeGeneratedAdaptersObserver里面的onStateChanged方法 在CompositeGeneratedAdaptersObserver里面遍历mGeneratedAdapters,然后也是调用callMethods方法 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public class CompositeGeneratedAdaptersObserver implements GenericLifecycleObserver { private final GeneratedAdapter[] mGeneratedAdapters; CompositeGeneratedAdaptersObserver(GeneratedAdapter[] generatedAdapters) { mGeneratedAdapters = generatedAdapters; } @Override public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) { MethodCallsLogger logger = new MethodCallsLogger(); for (GeneratedAdapter mGenerated: mGeneratedAdapters) { mGenerated.callMethods(source, event, false, logger); } for (GeneratedAdapter mGenerated: mGeneratedAdapters) { mGenerated.callMethods(source, event, true, logger); } } } 最后看一下ReflectiveGenericLifecycleObserver类的代码 反射调用回调函数,不过这里听过class对象,从ClassesInfoCache获取info信息。先从map里拿,拿不到通过createInfo函数扫描类里面的方法。具体分析可以看源码…… class ReflectiveGenericLifecycleObserver implements GenericLifecycleObserver { private final Object mWrapped; private final CallbackInfo mInfo; ReflectiveGenericLifecycleObserver(Object wrapped) { mWrapped = wrapped; mInfo = ClassesInfoCache.sInstance.getInfo(mWrapped.getClass()); } @Override public void onStateChanged(LifecycleOwner source, Event event) { mInfo.invokeCallbacks(source, event, mWrapped); } } 当addObserver的时候最后实际传入的是一个包装好的ObserverWithState对象 然后调用onStateChanged方法来分发状态。使用处理器来提高性能,避免反射造成的性能消耗。 07.知识点梳理和总结一下 Lifecycle 库通过在 SupportActivity 的 onCreate 中注入 ReportFragment 来感知发生命周期; Lifecycle 抽象类,是 Lifecycle 库的核心类之一,它是对生命周期的抽象,定义了生命周期事件以及状态,通过它我们可以获取当前的生命周期状态,同时它也奠定了观察者模式的基调;(我是党员你看出来了吗:-D) LifecycleOwner ,描述了一个拥有生命周期的组件,可以自己定义,不过通常我们不需要,直接使用 AppCompatActivity 等即可; LifecycleRegistry 是Lifecycle的实现类,它负责接管生命周期事件,同时也负责Observer` 的注册以及通知; ObserverWithState ,是 Observer 的一个封装类,是它最终 通过 ReflectiveGenericLifecycleObserve 调用了我们用注解修饰的方法; LifecycleObserver ,Lifecycle 的观察者,利用它我们可以享受 Lifecycle 带来的能力; ReflectiveGenericLifecycleObserver,它存储了我们在 Observer 里注解的方法,并在生命周期发生改变的时候最终通过反射的方式调用对应的方法。 参考博客 https://developer.android.com/topic/libraries/architecture/lifecycle https://www.jianshu.com/p/7087f1dae359 https://mp.weixin.qq.com/s/P22w7K0vS5s0A9M4HkBgzQ https://mp.weixin.qq.com/s/xxYoyLXIIr8zHMz9BbpnAg 开源LiveData事件总线:https://github.com/yangchong211/YCLiveDataBus
目录介绍 01.LiveData是什么东西 02.使用LiveData的优势 03.使用LiveData的步骤 04.简单使用LiveData 05.observe()和observerForever() 06.LiveData原理介绍 07.observe订阅源码分析 08.setValue发送源码分析 09.observeForever源码 10.LiveData源码总结 00.使用LiveData实现bus事件总线 利用LiveData实现事件总线,替代EventBus。充分利用了生命周期感知功能,可以在activities, fragments, 或者 services生命周期是活跃状态时更新这些组件。支持发送普通事件,也可以发送粘性事件;还可以发送延迟消息,以及轮训延迟消息等等。 https://github.com/yangchong211/YCLiveDataBus 01.LiveData是什么东西 基于观察者模式 LiveData是一种持有可被观察数据的类。LiveData需要一个观察者对象,一般是Observer类的具体实现。当观察者的生命周期处于STARTED或RESUMED状态时,LiveData会通知观察者数据变化。 感知生命周期 和其他可被观察的类不同的是,LiveData是有生命周期感知能力的,这意味着它可以在activities, fragments, 或者 services生命周期是活跃状态时更新这些组件。那么什么是活跃状态呢?就是STARTED和RESUMED就是活跃状态,只有在这两个状态下LiveData是会通知数据变化的。 自动解除数据订阅 要想使用LiveData(或者这种有可被观察数据能力的类)就必须配合实现了LifecycleOwner的对象使用。在这种情况下,当对应的生命周期对象DESTORY时,才能移除观察者。这对Activity或者Fragment来说显