0%

该篇内容为原博客博文,原上传于2022年2月27日。可能已过时,仅作补档用途,谨慎参考!

前言

上次,我们初步对FFmpeg进行了交叉编译及简单移植。然而,FFmpeg的强大之处在于使用简单的命令行完成艰巨的音视频处理。如果仅仅是集成众多动态库,我们只能使用库中提供的函数编写代码完成功能,相比于使用命令行,对于初学者来说是比较复杂和困难的方法。所以这一次,我们将尝试移植FFmpeg的命令行,直接通过命令调用FFmpeg。然而,笔者的初次移植血泪史揭示,现有的大量博客都是过时的,按照他们的步骤来做,会导致移植失败,白白浪费心力。需要在一般步骤的基础上,加上其他必要的操作。

修改configure文件

在之前的步骤中,我们并没有深入讨论源码目录下的configure文件本身的内容,也没有提到编译脚本为什么这么写,哪些参数是必须配置的。接下来,我们打开configure文件,首先对FFmpeg编译生成的动态库的格式进行设置:

1
2
vim configure
:/SLIBNAME_WITH_MAJOR

注意本文基于ffmpeg5.0,其他版本configure文件内容可能并不一致。

查看并找到下面的内容:

1
2
3
4
SLIBNAME_WITH_MAJOR='$(SLIBNAME).$(LIBMAJOR)' 
LIB_INSTALL_EXTRA_CMD='$$(RANLIB) "$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_VERSION)'
SLIB_INSTALL_LINKS='$(SLIBNAME_WITH_MAJOR) $(SLIBNAME)'

将其修改为:

1
2
3
4
SLIBNAME_WITH_MAJOR='$(SLIBPREF)$(FULLNAME)-$(LIBMAJOR)$(SLIBSUF)'
LIB_INSTALL_EXTRA_CMD='$$(RANLIB)"$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_MAJOR)'
SLIB_INSTALL_LINKS='$(SLIBNAME)'

这一步是许多博客都提到过的,其原因在于,FFmpeg默认编译生成的so库会在后缀后面带上版本号,如libavcodec.so.57.107.100。Android Studio不支持导入这样的库。实际上,FFmpeg也会生成一般命名的so库,只是经过上面的修改后,在安装目录下的lib目录中就只会生成一般命名的so库了,这会使得lib目录更加简洁美观。

接下来,我们找到if test "$target_os" = android处,注意到这里对应上了我们在编译脚本中配置的参数target-os=android,这里规定了一些默认配置,然而仔细观察,不难发现其中有些地方是有问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if test "$target_os" = android; then
cc_default="clang"
fi

ar_default="${cross_prefix}${ar_default}"
cc_default="${cross_prefix}${cc_default}"
cxx_default="${cross_prefix}${cxx_default}"
nm_default="${cross_prefix}${nm_default}"
pkg_config_default="${cross_prefix}${pkg_config_default}"
if ${cross_prefix}${ranlib_default} 2>&1 | grep -q "\-D "; then
ranlib_default="${cross_prefix}${ranlib_default} -D"
else
ranlib_default="${cross_prefix}${ranlib_default}"
fi
strip_default="${cross_prefix}${strip_default}"
windres_default="${cross_prefix}${windres_default}"

首先,上面的脚本没有设置当 target-os 为 android 时,c++ 的默认编译器;此外,其将ar,cc,cxx等等需要的参数均设置成以 cross_prefix 为前缀,然而找到我们工具链的bin目录下(如笔者这里是ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64/bin),可以发现相应的工具前缀是llvm-,而不是脚本里设置的cross_prefix,即aarch64-linux-android-。如果不修改,就会在最后移植时导致一系列问题。此外,其对pkg_config的配置也有问题。我们的工具链并没有内置pkg_config,所以这里也要改成我们自己安装的pkg_config。我们考虑以上列出的问题,将以上代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if test "$target_os" = android; then
cc_default="clang"
cxx_default="clang++"
fi

ar_default="llvm-${ar_default}"
cc_default="${cross_prefix}${cc_default}"
cxx_default="${cross_prefix}${cxx_default}"
nm_default="llvm-${nm_default}"
pkg_config_default="${pkg_config_default}"
if ${cross_prefix}${ranlib_default} 2>&1 | grep -q "\-D "; then
ranlib_default="llvm-${ranlib_default} -D"
else
ranlib_default="llvm-${ranlib_default}"
fi
strip_default="llvm-${strip_default}"
windres_default="${cross_prefix}${windres_default}"

注意到我们将涉及到前缀问题的参数均修改为了正确的前缀(有的参数暂时用不到,就没有修改),此外,我们将cxx_default重写为clang++。我们没有改写下面的cc_default和cxx_default,因为我们的脚本中已经设置了正确的cc和cxx,不会用到错误的默认值。到这里,configure就算改好了。这里我列出的代码仅以我自己的配置为准,务必要根据自己的工具链选择是否改写。
我们再回头看一眼编译脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
TOOLCHAIN=$HOME/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64

export PATH=$PATH:$TOOLCHAIN/bin

CROSS_PREFIX=aarch64-linux-android
CROSS_PREFIX_CLANG=aarch64-linux-android21
PREFIX=$HOME/open/FFmpeg/src/ffmpeg-5.0-arm64
ANDROID_API=21
ARCH=aarch64
CPU=armv8-a

# configure默认的CC与CXX路径是错误的,需要修正
export CC=$CROSS_PREFIX_CLANG-clang
export CXX=$CROSS_PREFIX_CLANG-clang++

./configure \
--prefix=$PREFIX \
--enable-shared \
--enable-postproc \
--enable-gpl \
--enable-jni \
--disable-static \
--enable-cross-compile \
--disable-doc \
--cross-prefix=$CROSS_PREFIX- \
--cc=$CC \
--cxx=$CXX \
--sysroot=$TOOLCHAIN/sysroot \
--extra-cflags="-D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS" \
--arch=$ARCH \
--cpu=$CPU \
--target-os=android

make clean
make -j4
make install

echo "build android ffmpeg finished."

运行脚本,我们像上次一样,在安装目录得到了bin,include,lib,share几个目录,表示编译成功。

移植命令行

首先,我们像上次一样,拷贝lib目录下所有so库至我们的Android项目中。接下来,我们首先考虑如何实现命令行的移植。我们在使用ffmpeg命令的时候,实际上是在将命令行参数传给ffmpeg的main函数,并由main函数具体执行命令。那么我们要做的事情也就清晰了,即在android项目中使用ffmpeg的main函数,并向其传递参数。接下来的步骤如下:

我们拷贝整个include目录。这里我直接将libs和include目录放在和src同级(注意我们不建议在实际项目中这样组织目录层次。官方推荐的做法是将原生相关的文件统一放在cpp目录下管理)。可以看见,在include目录中,是我们所需要的众多头文件。

接下来,我们回到ffmpeg源码目录,这里有我们需要用到的一些文件。首先是fftools文件夹,这里是命令行调用ffmpeg的工具。我们复制其中的cmdutils.h和ffmpeg.h到android项目的include文件夹下(别忘了include是我们存放众多头文件的地方),再把cmdutils.c, ffmpeg.c, ffmpeg_filter.c, ffmpeg_hw.c, ffmpeg_opt.c五个文件复制到libs目录下。此外,我们还需要源码目录下编译后生成的config.h,将它也复制到include目录下。注意这里文件存放的位置不是绝对的,只要保证能合理地编写CMakeLists即可

到此,我们的目录结构如下:

结构1

结构2

接着,我们还需要修改其中的一部分代码,以使其适应Android APP下的应用情景。参考博客:https://blog.csdn.net/yhaolpz/article/details/77146156#commentBox

  1. 修改日志输出源
    我们希望能在调用ffmpeg时,将其日志输出导入至AS的logcat中,方便我们的开发与调试。为此,我们首先引入android的log库。在CMakeLists中新增如下代码:
1
2
3
4
5
6
find_library(log-lib log)
...
target_link_libraries(
...
${log-lib}
)

接下来,在include目录写一个androidlog.h文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifdef ANDROID
#include <android/log.h>
#ifndef LOG_TAG
#define MY_TAG "MYTAG"
#define AV_TAG "AVLOG"
#endif
#define LOGE(format, ...) __android_log_print(ANDROID_LOG_ERROR, MY_TAG, format, ##__VA_ARGS__)
#define LOGD(format, ...) __android_log_print(ANDROID_LOG_DEBUG, MY_TAG, format, ##__VA_ARGS__)
#define XLOGD(...) __android_log_print(ANDROID_LOG_INFO,AV_TAG,__VA_ARGS__)
#define XLOGE(...) __android_log_print(ANDROID_LOG_ERROR,AV_TAG,__VA_ARGS__)
#else
#define LOGE(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define LOGD(format, ...) printf(MY_TAG format "\n", ##__VA_ARGS__)
#define XLOGE(format, ...) fprintf(stdout, AV_TAG ": " format "\n", ##__VA_ARGS__)
#define XLOGI(format, ...) fprintf(stderr, AV_TAG ": " format "\n", ##__VA_ARGS__)
#endif

打开ffmpeg.c,引入头文件#include "androidlog.h",并实现原本为空的logg_callback_null函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void log_callback_null(void *ptr, int level, const char *fmt, va_list vl)
{
static int print_prefix = 1;
static int count;
static char prev[1024];
char line[1024];
static int is_atty;
av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
strcpy(prev, line);
if (level <= AV_LOG_WARNING){
XLOGE("%s", line);
}else{
XLOGD("%s", line);
}
}

在ffmpeg.c的main函数第一行加入如下代码:

1
av_log_set_callback(log_callback_null);

这样,以后在调用ffmpeg命令时,就可以像在shell里一样在logcat获得ffmpeg输出的日志信息。

  1. 执行命令后清除数据并保留进程
    在ffmpeg.c的ffmpeg_cleanup中末尾加上如下代码:
1
2
3
4
5
nb_filtergraphs = 0;
nb_output_files = 0;
nb_output_streams = 0;
nb_input_files = 0;
nb_input_streams = 0;

在ffmpeg.c的main函数的return语句调用前增加如下代码:

1
ffmpeg_cleanup(0);

再修改cmdutils.c中的exit_program函数如下:

1
2
3
4
int exit_program(int ret)
{
return ret;
}

注意由于我们修改了函数的返回值,故还需要到cmdutils.h中修改对应的函数声明:

1
int exit_program(int ret);

以上都完成后,不要忘了在ffmpeg.h中加上对main函数的声明,否则我们调用不到ffmpeg.c的main函数。大多数博客都喜欢将main函数的函数名改写为run, exec之类的其他名称,这个因人而异,修不修改没有问题。
到此,对ffmpeg源文件的修改就结束了,大多数博客也就到此结束了,他们可以愉快地在android中使用ffmpeg命令了。但此时如果我们尝试启动APP,会发现AS报出一长串的错误,都是找不到头文件。此时不要慌张,回到ffmpeg源码目录下寻找缺失的头文件并逐一复制即可。这里我丢失的头文件如下,可供参考:

  • compat目录下:va_copy.h
  • libavcodec目录下:matops.h
  • libavformat目录下:os_support.h
  • libavutil目录下:aarch64等架构目录(主要需要的是其中的timer.h),internal.h,libm.h,thread.h,timer.h

再次启动APP,应该编译通过,APP正常启动。如果你的AS依然报错,且不是丢失头文件,而是各种语法问题(如内联汇编限制符),你可以先尝试修正,如果始终不行,请检查ffmpeg编译脚本是否正确编写,目标架构等参数是否配置正确。

我们可以编写一个测试用例,这里我之前有一个将wav格式音频转码为aac格式的业务需求,是使用android的MediaCodec API实现的,其代码逻辑复杂冗长,我们尝试使用简单的ffmpeg命令实现这个需求。

编写如下工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
object FFmpegUtils {
init {
System.loadLibrary("jnitest")
}

fun WAV2AAC(pcmPath: String, aacPath: String) {
val command = arrayOf(
"ffmpeg",
"-i",
pcmPath,
aacPath
)
exec(command)
}

private external fun exec(args: Array<String>)
}

我们先是加载了native库,并且声明了一个native方法,用于接收命令参数。exec函数的原形如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
JNIEXPORT void JNICALL
Java_com_eynnzerr_medialearning_utils_FFmpegUtils_exec(JNIEnv *env, jobject thiz,
jobjectArray args) {
int i = 0;
int argc = 0;
char **argv = NULL;
jstring *strr = NULL;

if (args != NULL) {
argc = (*env)->GetArrayLength(env, args);
argv = (char **) malloc(sizeof(char *) * argc);
strr = (jstring *) malloc(sizeof(jstring) * argc);

av_log(NULL, AV_LOG_INFO, "argc is %d", argc);

for (i = 0; i < argc; i ++) {
strr[i] = (jstring) (*env)->GetObjectArrayElement(env, args, i); // strr将传入的kotlin string转为jstring
argv[i] = (char *) (*env)->GetStringUTFChars(env, strr[i], 0); // argv将jstring转化为UTF Chars
av_log(NULL, AV_LOG_INFO, "receive argv: %s", argv[i]);
}
}

main(argc, argv);

for(i = 0; i < argc; i ++) {
(*env)->ReleaseStringUTFChars(env, strr[i], argv[i]);
}
free(argv);
free(strr);
}

实际测试,完美运行,成功移植FFmpeg命令行至Android平台。

该篇内容为原博客博文,原上传于2022年2月12日。可能已过时,仅作补档用途,谨慎参考!

前言

FFmpeg是一款开源音视频处理工具,简单易用,功能强大。将FFmpeg移植到Android平台上,可以使得我们在从事 Android 音视频开发时如虎添翼。上回我们已经在 ubuntu 上初步配置和安装了 FFmpeg,但这样还是远远不够的。接下来就探讨一下如何在 Android Studio 中集成FFmpeg。

适用版本

  • FFmpeg 5.0 “Lorentz”
  • ubuntu 20.04
  • NDK 21.4
  • CMake 3.18
  • Android Studio Bumblebee 2021.1.1

其中NDK和CMake为方便演示使用的是AS自带的版本,如果使用自己安装的版本可以参考官方教程进行配置。

Step1. 重新编译FFmpeg

FFmpeg本身是C语言编写的,所以我们需要使用NDK将其编译成适用于Android平台的so库,并通过jni进行调用。所以仅由我们在上一篇中简单的配置而产生的FFmpeg库是完全不能胜任的,需要设置必要的配置参数,重新编译。

首先运行

1
make uninstall & make distclean

以卸载和清除上次编译产生的文件。由于需要设置的参数较多,这一次我们选择编写Shell脚本来对FFmpeg进行配置和编译。这里我的脚本参考了同组学长 CarriSun 的写法,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
TOOLCHAIN=$HOME/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64

export PATH=$PATH:$TOOLCHAIN/bin

CROSS_PREFIX=aarch64-linux-android
CROSS_PREFIX_CLANG=aarch64-linux-android21
PREFIX=$HOME/open/ffmpeg
ANDROID_API=21
ARCH=arm64
CPU=armv8-a

export CC=$CROSS_PREFIX_CLANG-clang
export CXX=$CROSS_PREFIX_CLANG-clang++

./configure \

--prefix=$PREFIX \
--enable-shared \
--disable-static \
--enable-cross-compile \
--disable-doc \
--cross-prefix=$CROSS_PREFIX- \
--cc=$CC \
--cxx=$CXX \
--sysroot=$TOOLCHAIN/sysroot \
--extra-cflags="-isysroot $TOOLCHAIN/sysroot -isystem $TOOLCHAIN/sysroot/usr/include/aarch64-linux-android -D__ANDROID_API__=$ANDROID_API -U_FILE_OFFSET_BITS" \
--arch=$ARCH \
--cpu=$CPU \
--target-os=android

make clean
make -j4
make install

echo "Build FFmpeg on arm64 finished."

TOOLCHAIN 指定了编译时的工具链,ARCH 和 CPU 指定了目标平台支持的架构和CPU(重要)。

对该脚本 chmod 和执行后,我们就重新编译出了其中一种适用于 Android 平台的 FFmpeg 了。转到安装目录下,可以看到include目录下的头文件和lib目录下的动态库。

step2. 配置Android Studio,导入动态库

打开Android Studio选择已有的项目(或者新建Native C++项目)。在模块级 build.gradle 中加入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
android {
...

externalNativeBuild {
cmake {
path = "CMakeLists.txt"
version = "3.18.1"
}
}

defaultConfig {
...

ndk{ abiFilters 'arm64-v8a' }

...
}
}

其中cmake项是配置CMakeLists的路径和CMake的版本。
默认情况下,Gradle(无论是通过 Android Studio 使用,还是从命令行使用)会针对所有非弃用 ABI 进行构建。要限制应用支持的 ABI 集,请使用 abiFilters。如这里我填入了arm64-v8a,正是对应了编译FFmpeg时指定的目标CPU和架构。如果这里不设置abiFilter的话,之后构建app会出现问题。

关于abi,强烈建议读一下官网的说明:Android ABI

接下来,我们将编译生成的FFmpeg目录下include目录复制到app目录下,并创建cpp/arm64-v8a目录,且将FFmpeg/lib目录下所有so库复制过来。然后,我们就可以准备着手写CMakeList了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
cmake_minimum_required(VERSION 3.18)

project(jnitest C)

set(CMAKE_C_STANDARD 99)

aux_source_directory(/home/eynnzerr/open/AndroidProjectX/MediaLearning/app/cpp DIR_LIB)

add_library(jnitest SHARED ${DIR_LIB})

include_directories(include)

add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavcodec.so)

add_library(avdevice SHARED IMPORTED)
set_target_properties(avdevice PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavdevice.so)

add_library(avfilter SHARED IMPORTED)
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavfilter.so)

add_library(avformat SHARED IMPORTED)
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavformat.so)

add_library(avutil SHARED IMPORTED)
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libavutil.so)

add_library(swresample SHARED IMPORTED)
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libswresample.so)

add_library(swscale SHARED IMPORTED)
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI}/libswscale.so)

target_link_libraries(
jnitest
avcodec
avdevice
avfilter
avformat
avutil
swresample
swscale
)

以上就是添加FFmpeg的共享库并将它们全部链接进来。至此,准备工作全部完成,sync一下项目即可。

Step.3 测试

cpp目录下引入jni头文件后,编写测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "jni.h"

#ifndef _Included_JNITest
#define _Included_JNITest

#ifdef __cplusplus
extern "C" {
#endif
#include <libavutil/avutil.h> //库导入放在extern C内!FFmpeg是C编写的。

JNIEXPORT jstring JNICALL Java_com_eynnzerr_medialearning_MainActivity_getString(JNIEnv *env, jobject thiz) {
return (*env)->NewStringUTF(env, avutil_configuration());
}

#ifdef __cplusplus
}
#endif
#endif

这里简单返回一下FFmpeg的配置信息。对应原生方法:

1
2
3
4
5
6
companion object {
init {
System.loadLibrary("jnitest")
}
}
private external fun getString(): String

我们在Log中打印出来看看:

1
Log.d(TAG, "onCreate: msg from JNI: ${getString()}")

开始构建app。注意由于这里我们选择的架构是arm64-v8a,因此app只能在支持该架构的手机上(虚拟机或实机)运行,Android Studio会给出这方面的警告。想要增加支持的架构,就可以指定不同的CPU多次编译FFmpeg,并在abiFilter中添加。

运行app,可以看到日志中确实输出了相关信息,证明FFmpeg已经成功移植到了Android平台。

报错记录

总体上来说,第一次尝试AS集成FFmpeg,整个过程还算比较顺利,只有在最后报了一些错误,也都已经解决,但秉持会有和我一样的小白也可能遇到同样的问题,特此记录,相互学习。

动态库路径问题

信息:ffmpeg: error while loading shared libraries: libavdevice.so.59: cannot open shared object file: No such file or directory

原因:通过源码安装软件未进行库搜索路径配置,系统找不到ffmpeg动态库路径。

解决sudo vim /etc/ld.so.conf 加上ffmpeg的lib文件夹路径,然后sudo ldconfig

架构匹配问题

信息

  1. clang: error: linker command failed with exit code 1 (use -v to see invocation)
  2. C/C++: /…/Android/Sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/linux-x86_64/lib/gcc/i686-linux-android/4.9.x/../../../../i686-linux-android/bin/ld: error: ../../../../libs/libswscale.so: incompatible target
  3. C/C++: ../../../../cpp/main.c:16: error: undefined reference to ‘avutil_configuration’

原因:(在上面已经提到过)第一次移植ffmpeg时,app总是不能通过编译。对代码反复修改后仍未解决,并报出以上错误。其实从第二条错误信息中的incompatible target就可以看出,这有可能是因为目标架构不匹配的原因。下面是stack overflow上热心码友给出的解释:

The error you’re getting means that ndk-build is trying to use your .so file while compiling your module for an incompatible target.
Android supports several cpu architectures (armeabi, armeabi-v7a, arm64-v8a, x86, x86_64, mips, mips64). You can choose which one you want to support using the APP_ABI variable from Application.mk. If you set it to all, ndk-build will try to use this .so file you’re referencing for each of these architectures, but this cannot work.
Your .so file must have been compiled for Android platforms, and you need to have a different version of it for each architecture you’re supporting. You can give a dynamic reference to the right .so file, such as LOCAL_SRC_FILES := $(TARGET_ARCH_ABI)/libslabhidtouart.so so it looks for your .so file under armeabi-v7a folder when compiling for armeabi-v7a, under x86 for x86, etc.
Of course you need to provide these .so files. If you can’t get .so files for all the supported architectures, you’ll have to restrict your APP_ABI to the architectures of the .so file you have. You can determine the architecture your .so file has been compiled for using readelf.

然而,现如今互联网上大部分博客都没有强调ffmpeg移植的架构匹配性问题,有几篇中有所涉及,但也没有解释原因 (这里再次感叹音视频学习资料匮乏)

解决:首先在编译ffmpeg时,通过–cpu参数和–arch指定目标CPU与架构,再在android studio中配置ndk的abiFilter。

Android Studio 兼容性问题

信息:2 files found with path ‘lib/armeabi-v7a/libbufferhub.so‘ from inputs

原因:早期版本的Android Gradle插件要求使用以下命令显式打包CMake外部本机内部版本使用的所有预构建库 jniLibs:

1
2
3
4
5
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}

使用Android Gradle Plugin 4.0时,不再需要上述配置,并且会导致构建失败。现在,外部本机构建会自动打包这些库,因此将jniLibs结果与库明确打包在一起。为避免生成错误,只需jniLibs从build.gradle文件中删除配置即可。绝大多数过时的博客都因此极具误导性。

解决:在app:build.gradle中去掉sourceSets的设置

该篇内容为原博客博文,原上传于2022年10月29日。

前置知识

自动内存管理

说到程序的内存管理,C/C++开发人员具有绝对的权利。通过malloc()new,他们可以极其奔放地分配内存,自由掌控对象的“所有权”。然而能力越大责任也越大,伴随着操控内存的快感之后而来的则是维护内存的无尽痛苦。编译器不能发现潜在的内存问题,必须由开发者提前避免。喜闻乐见的问题包括但不限于:

  • 判断分配内存有效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
sws = sws_getContext(
in_width,
in_height,
AV_PIX_FMT_YUV420P,
out_width,
out_height,
AV_PIX_FMT_YUV420P,
SWS_FAST_BILINEAR,
nullptr,
nullptr,
nullptr
);

if (!sws) {
av_log(nullptr, AV_LOG_INFO, "Cannot create sws context.\n");
}

// open writing stream of output file
outputFile = fopen(outputPath, "wb");
if (!outputFile) {
av_log(nullptr, AV_LOG_ERROR, "Failed to open output file!\n");
}

...

if (!x264Param) delete x264Param;
x264Param = new x264_param_t;
int ret = x264_param_default_preset(x264Param, "fast", "zerolatency");
if (ret < 0) {
av_log(nullptr, AV_LOG_ERROR, "Failed to set preset parameter!\n");
}

...

ret = x264_param_apply_profile(x264Param, x264_profile_names[1]);
if (ret < 0) {
av_log(nullptr, AV_LOG_ERROR, "Failed to apply main profile!\n");
}

encoder = x264_encoder_open(x264Param);
if(!encoder) {
av_log(nullptr, AV_LOG_ERROR, "Failed to open x264 encoder!\n");
}

// Write headers to file
int header_size = x264_encoder_headers(encoder, &nals, &nalCount);
if(header_size < 0) {
av_log(nullptr, AV_LOG_ERROR, "Error when calling x264_encoder_headers()!\n");
}
// outputStream << nals[0].p_payload;
if (!fwrite(nals[0].p_payload, sizeof(uint8_t), header_size, outputFile)) {
av_log(nullptr, AV_LOG_ERROR, "Failed to write header!\n");
}
  • 必要的内存释放代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
X264Encoder::~X264Encoder() {
if (encoder) {
x264_picture_clean(&inFrame);
x264_encoder_close(encoder);
encoder = nullptr;
}
if (outputFile) {
fclose(outputFile);
outputFile = nullptr;
}
if (sws) {
sws_freeContext(sws);
sws = nullptr;
}
delete mbQp;
}
  • 其它:野指针、越界、…(虽然这些多半是程序员自己粗心大意)

于是有人想到,如果能够自动管理对象的生命周期,写代码的时候可能就可以少考虑一些麻烦事,少写一些模版化的代码了。这就是自动内存管理的诞生。jvm 正具有自动内存管理的能力,对于 jvm 系语言的程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要操心为每一个new操作写配对的delete/free,也因此大大减少了内存泄漏和内存溢出的可能。虽然将内存控制的权利交给 jvm ,可以为开发者省去不少麻烦,但也正因如此,一旦真的出现了内存相关问题,如果不了解 jvm 的内存结构和管理策略,排查错误也将无从下手。

jvm内存结构

对象的自动清理——GC

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器就减1。回收计数器值为0的对象内存。
优点 实现简单,判定效率高。
缺点 不能解决循环引用对象的回收。

可达性分析算法

以一系列被称为 GC Roots 的对象为根,从这些根节点开始向下搜索,搜索途径的路径称为引用链。当一个对象到 GC Roots 没有任何引用链(图论中的不可达),则证明该对象不可用,可以被回收。

可作为 GC Roots 的对象:

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

.hprof文件

.hprof是 jvm 的堆内存快照文件,可用于分析内存泄漏等异常问题。对其文件结构感兴趣的同学可以阅读协议文档

四种引用

强引用

强引用就是在代码中普遍存在的,类似val obj = Any()(new in java )这类的引用。只要强引用仍存在,GC 就永远不会回收被引用的对象。

软引用

用以描述尚有用但非必需的对象。对于软引用关联的对象,在系统即将发生 OOM 之前,会被 GC 列入回收范围之中进行第二次回收,如果这次回收后依然没有足够内存,则抛出 OOM 异常。

1
val obj = SoftReference(Any())

弱引用

也用以描述非必需对象,但强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次 GC 发生之前。当 GC 工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象(即 LeakCanary 中所指的弱可达)。

1
val obj = WeakReference(Any())

虚引用

也称为幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用获取对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被 GC 回收时收到一个系统通知。

1
val obj = PhantomReference(Any())

引用队列

当注册的引用型对象(软引用、弱引用、虚引用)在 GC 检测到所引用的对象可达性发生改变时,会将这个引用型的对象添加到引用队列中。引用队列实际上只是持有着已经不再引用堆中的要被清除的对象的引用型对象,并不能使对象再次存活下去,其用处只是为了提醒程序员非强引用型变量所引用的对象已经具有不可达性,即这个对象已经从堆中拿不到了。

LeakCanary

LeakCanary 是 Android 开源社区巨头 Square 公司出品的 Android app 内存泄漏检测工具。

2.x版本Logo: 一只寄了的金丝雀

2.0-alpha-1版本以后,LeakCanary 经由纯 kotlin 重写,并且更新了许多 API 沿用至今,故此处只简要介绍 LeakCanary2 的使用,以及基于 LeakCanary2 源码分析其原理。

基本使用

  1. 引入该库依赖。
1
2
3
4
5
6
7
val leakCanaryVersion = "2.9.1"

dependencies {
...
debugImplementation("com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion")
...
}
  1. 模拟一个典型的内存泄漏场景
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@DelicateCoroutinesApi
class MainActivity : ComponentActivity() {

// 匿名内部类隐式持有activity引用
private val handler: Handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
Log.d(TAG, "handleMessage: handler msg: ${msg.what}")
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
val context = LocalContext.current

AVPlayerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = {
GlobalScope.launch(Dispatchers.IO) {
while (true) {
handler.sendEmptyMessage(1)
delay(1000)
}
}
Toast.makeText(context, "start job.", Toast.LENGTH_SHORT).show()
},
) {
Text(text = "start job")
}
Button(
onClick = {
startActivity(Intent(context, SecondActivity::class.java))
finish()
}
) {
Text(text = "start activity")
}
}
}
}
}
}

override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy: called")
}

companion object {
private const val TAG = "MainActivity"
}
}

这里我们以匿名内部类的方式在MainActivity中创建了一个Handler,将主线程的looper传给该handler,并创建了一个协程让handler不断向主线程发送空消息。点击第一个按钮将启动该协程,第二个按钮将跳转至另一个activity并立即销毁当前activity

分析不难得知,虽然销毁了activity,但协程中的线程依然活跃并作为GC Root 对象。而该线程又持有handler实例,handler作为内部类又隐式持有外部mainActivity实例,故存在以下引用链使得mainActivity可达(随便找了个在线画图网页,请无视掉水印):

  1. 启动应用,不一会儿便可以看到LeakCanary弹出一则通知,表示探测到了内存泄漏:

点击该通知,将开始导出并分析app运行时jvm堆的信息。当提示完成时,再次点击通知,将进入LeakCanary提供的客户端:

点击这个刚感知到的内存泄漏条目,堆的信息将以可视化的形式呈现:

或者我们也可以从日志中获取引用链的信息,示例输出的日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
====================================
HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS

References underlined with "~~~" are likely causes.
Learn more at https://squ.re/leaks.

108144 bytes retained by leaking objects
Signature: 2dbd12c5ad0a3810a8158c3cd35a69dcb07f496d
┬───
│ GC Root: Thread object

├─ java.lang.Thread instance
│ Leaking: UNKNOWN
│ Retaining 254 B in 7 objects
│ Thread name: 'kotlinx.coroutines.DefaultExecutor'
│ ↓ Thread.parkBlocker
│ ~~~~~~~~~~~
├─ kotlinx.coroutines.DefaultExecutor instance
│ Leaking: UNKNOWN
│ Retaining 108.5 kB in 2579 objects
│ ↓ EventLoopImplBase._delayed
│ ~~~~~~~~
├─ kotlinx.coroutines.EventLoopImplBase$DelayedTaskQueue instance
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2578 objects
│ ↓ ThreadSafeHeap.a
│ ~
├─ kotlinx.coroutines.internal.ThreadSafeHeapNode[] array
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2577 objects
│ ↓ ThreadSafeHeapNode[0]
│ ~~~
├─ kotlinx.coroutines.EventLoopImplBase$DelayedResumeTask instance
│ Leaking: UNKNOWN
│ Retaining 108.4 kB in 2576 objects
│ ↓ EventLoopImplBase$DelayedResumeTask.cont
│ ~~~~
├─ kotlinx.coroutines.CancellableContinuationImpl instance
│ Leaking: UNKNOWN
│ Retaining 108.3 kB in 2575 objects
│ ↓ CancellableContinuationImpl.delegate
│ ~~~~~~~~
├─ kotlinx.coroutines.internal.DispatchedContinuation instance
│ Leaking: UNKNOWN
│ Retaining 108.2 kB in 2570 objects
│ ↓ DispatchedContinuation.continuation
│ ~~~~~~~~~~~~
├─ com.eynnzerr.avplayer.MainActivity$onCreate$1$1$1$1$1$1 instance
│ Leaking: UNKNOWN
│ Retaining 108.2 kB in 2569 objects
│ Anonymous subclass of kotlin.coroutines.jvm.internal.SuspendLambda
│ this$0 instance of com.eynnzerr.avplayer.MainActivity with mDestroyed = true
│ ↓ MainActivity$onCreate$1$1$1$1$1$1.this$0
│ ~~~~~~
╰→ com.eynnzerr.avplayer.MainActivity instance
​ Leaking: YES (ObjectWatcher was watching this because com.eynnzerr.avplayer.MainActivity received
​ Activity#onDestroy() callback and Activity#mDestroyed is true)
​ Retaining 108.1 kB in 2568 objects
​ key = 427e7b26-a712-4eee-81f2-3040f34111a8
​ watchDurationMillis = 125452
​ retainedDurationMillis = 120450
​ mApplication instance of android.app.Application
​ mBase instance of android.app.ContextImpl
====================================
0 LIBRARY LEAKS

A Library Leak is a leak caused by a known bug in 3rd party code that you do not have control over.
See https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/#4-categorizing-leaks
====================================
0 UNREACHABLE OBJECTS

An unreachable object is still in memory but LeakCanary could not find a strong reference path
from GC roots.
====================================
METADATA

Please include this in bug reports and Stack Overflow questions.

Build.VERSION.SDK_INT: 30
Build.MANUFACTURER: unknown
LeakCanary version: 2.9.1
App process name: com.eynnzerr.avplayer
Class count: 19677
Instance count: 104943
Primitive array count: 86281
Object array count: 17593
Thread count: 20
Heap total bytes: 16092598
Bitmap count: 0
Bitmap total bytes: 0
Large bitmap count: 0
Large bitmap total bytes: 0
Stats: LruCache[maxSize=3000,hits=35774,misses=78728,hitRate=31%]
RandomAccess[bytes=3855083,reads=78728,travel=23243159115,range=18698859,size=24675970]
Heap dump reason: user request
Analysis duration: 3073 ms
Heap dump file path: /storage/emulated/0/Download/leakcanary-com.eynnzerr.avplayer/2022-10-27_22-39-44_274.hprof
Heap dump timestamp: 1666881589949
Heap dump duration: 1291 ms

根据以上日志输出分析,以线程对象Thread object作为 GC Root,中间途径一系列协程内部的引用链,到达continuation对象,由continuation又能达到一个限定名为com.eynnzerr.avplayer.MainActivity$onCreate$1$1$1$1$1$1的奇怪对象,根据日志信息可以判定这个对象实际上是该协程的SuspendLambda匿名内部类对象,它封装了函数体内的代码块,也即间接代表着handler实例,因此便能最终到达MainActivity的实例,与理论相符。

原理分析

下面我们结合 LeakCanary2 的源码,对其运行原理进行分析。

非侵入式注册

首先,如果我们在项目中用过 LeakCanary1,不难会发现 LeakCanary2 相对于 1.x 版本一大变化在于,曾经我们需要在项目中重写Application并在其onCreate()方法中手动装载LeakCanary的监视器RefWatcher,为其传入context对象以完成其初始化:

1
2
3
...
refWatcher = LeakCanary.install(this);
...

但在 LeakCanary2 中,我们不再需要向项目中新增任何代码,只需要引入依赖即可。这是怎么实现的呢?实际上,这里用到了四大组件中ContentProvider的特性:在ContentProvider的生命周期中,它是在Application.attach()之后和Application.create()之前调用ContentProvider.onCreate()初始化,也即不需要我们手动进行显式初始化,并且此时也能获取到context,故可以通过一个ContentProvider实现非侵入式的注册,不会向现有项目引入任何注册相关的代码。所以在阅读源码时,也就不难理解为什么这里会继承ContentProvider。并且因为我们只是利用了contentProvider不仅能自动创建,还能在创建时拿到context的特性实现非侵入式注册,故对contentProvider的增删改查函数自然也没有实现(= null)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
internal class MainProcessAppWatcherInstaller : ContentProvider() {

override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
AppWatcher.manualInstall(application)
return true
}

// 增删改查的零实现
override fun query(
uri: Uri,
projectionArg: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? = null

override fun getType(uri: Uri): String? = null

override fun insert(uri: Uri, contentValues: ContentValues?): Uri? = null

override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0

override fun update(
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?
): Int = 0
}

MainProcessAppWatcherInstaller将在项目构建后在Manifest中声明。

1
2
3
4
5
6
7
<application>
<provider
android:name="leakcanary.internal.MainProcessAppWatcherInstaller"
android:authorities="${applicationId}.leakcanary-installer"
android:enabled="@bool/leak_canary_watcher_auto_install"
android:exported="false" />
</application>

注意到这里将获取的context传入了AppWatcher.manualInstall()这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@JvmOverloads
fun manualInstall(
application: Application,
retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
) {
// 检查是否在主线程中
checkMainThread()

// 如果已经安装过了则抛异常
if (isInstalled) {
throw IllegalStateException(
"AppWatcher already installed, see exception cause for prior install call", installCause
)
}

// 检查输入参数 retainedDelayMillis 的合法性
check(retainedDelayMillis >= 0) {
"retainedDelayMillis $retainedDelayMillis must be at least 0 ms"
}
this.retainedDelayMillis = retainedDelayMillis

// debug时开启日志
if (application.isDebuggableBuild) {
LogcatSharkLog.install()
}

// Requires AppWatcher.objectWatcher to be set
LeakCanaryDelegate.loadLeakCanary(application)

// 为传入待注册的 InstallableWatcher 一一调用注册
watchersToInstall.forEach {
it.install()
}

// Only install after we're fully done with init.
installCause = RuntimeException("manualInstall() first called here")
}

注意到此处watchersToInstall有默认值appDefaultWatchers(application),实际上就是 LeakCanary 默认注册的一些预先定义的监视器:

1
2
3
4
5
6
7
8
9
10
11
fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}

这里也就解释了为什么 LeakCanary2 宣称可以自动检测ActivityFragmentAndViewModelRootViewService的内存泄漏。

捕捉内存泄漏

接下来我们继续看具体实现原理,以ActivityWatcher为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* Expects activities to become weakly reachable soon after they receive the [Activity.onDestroy]
* callback.
*/
class ActivityWatcher(
private val application: Application,
private val reachabilityWatcher: ReachabilityWatcher
) : InstallableWatcher {

private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy() callback"
)
}
}

override fun install() {
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}

override fun uninstall() {
application.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)
}
}

这段代码有趣的地方有很多,我们首先把注意力聚焦在lifecycleCallbacks字段上。注意到这是一个实现了Application.ActivityLifecycleCallbacks接口的对象,并且委托给了noOpDelegate。乍看这个接口要求实现Activity生命周期中各个阶段的回调,但是这里只重写了onActivityDestroyed()方法。之所以能这么做是因为我们把其它回调函数的实现委托给了noOpDelegate,而为什么要这么做在于后续我们只会用到onActivityDestroyed()这一个回调,以此防止重写其它回调方法,实现更安全的封装。

接着我们注意到,在回调的内部调用了reachabilityWatcher.expectWeaklyReachable()函数。这个函数名很有意思,我们首先解释一下何谓weaklyReachable,即弱可达,其意为当前实例在引用链中仅可通过弱引用到达。也就是说, leakCanary 在其内存泄漏检测中使用到了弱引用。实际上,这里就运用到了我们之前提到的特性:当一个实例弱可达时,其将会在下次 gc 中被回收;且如果在构建WeakReference时,传入一个ReferenceQueue,则当弱引用持有的对象被回收时, jvm 会将这个弱引用放入构造时关联的引用队列中。不用强引用,显然是因为强引用始终不会被回收;不用软引用,是因为软引用只会在OOM前被回收,不便于控制;不用虚引用,是因为虚引用与 gc 无关,不能起作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.ref.ReferenceQueue
import java.lang.ref.WeakReference

fun main() {
val rq = ReferenceQueue<Pair<String, Int>?>()
// var pair: Pair<String, Int>? = "kotlin" to 233 // 仿 LeakCanary 使用键值对 此处为强引用
val weakReference = WeakReference(pair, rq) // 创建弱引用并关联引用队列

println(rq.poll())

pair = null // 清除对键值对的强引用
System.gc() // 手动gc一把
Thread.sleep(1000) // 开始gc后阻塞retainedTimeMills时间,等待pair被回收

println(rq.poll())
}

以上测试代码输出如下:

1
2
null
java.lang.ref.WeakReference@2a84aee7

利用这个特性,我们便有可能感知到一个对象是否内存泄漏了,大致思路如下:以 Activity 为例, 监听 Activity 的回调,当 Activity 调用onDestroy()时,将其通过弱引用保存到一个ReferenceQueue中,在每次 gc 后等待一段时间,检查ReferenceQueue是否有值。正常情况下, Activity 在 destroy 后,生命周期走到尽头,理应被 gc 回收。此时除了我们添加的弱引用外不会再被引用,即此后 Activity 将处于弱可达状态。那么按照弱引用的特性,其将会在 gc 后被放入引用队列,即referenceQueue.poll()将返回弱引用值。但如果发生了内存泄露,即此时除了我们添加的弱引用外,Activity 还有别的强引用,那么 gc 时就不会回收该 Activity,即referenceQueue.poll()将一直返回null。据此,就能得知 Activity 是否发生了内存泄漏!

回到源码,我们说到在ActivityWatcher中设置了onActivityDestroyed的回调函数,并在其中调用了reachabilityWatcher.expectWeaklyReachable()函数。首先,这里的reachabilityWatcher是通过默认值传入的objectWatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// AppWatcher.kt
val objectWatcher = ObjectWatcher(
clock = { SystemClock.uptimeMillis() },
checkRetainedExecutor = {
check(isInstalled) {
"AppWatcher not installed"
}
mainHandler.postDelayed(it, retainedDelayMillis)
}, // 通过 Executor 实现延迟探测
isEnabled = { true }
)
...

fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
...
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ObjectWatcher.kt
class ObjectWatcher constructor(
private val clock: Clock,
private val checkRetainedExecutor: Executor,
private val isEnabled: () -> Boolean = { true }
) : ReachabilityWatcher {

private val watchedObjects = mutableMapOf<String, KeyedWeakReference>() // 以键值对形式保存传入的观察对象弱引用。当对象弱可达时会被移出map
private val queue = ReferenceQueue<Any>() // 维护了一个上文提到的引用队列
...
@Synchronized override fun expectWeaklyReachable(
watchedObject: Any, // 调用方法时传入观察对象,如 Activity
description: String
) {
if (!isEnabled()) {
return
}
// 由于我们判别内存泄漏的依据是看引用队列是否返回null,故先在引用队列中将弱可达的引用清除。
removeWeaklyReachableObjects()
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference =
KeyedWeakReference(
watchedObject,
key,
description,
watchUptimeMillis,
queue
) // 构造弱引用,关联引用队列

watchedObjects[key] = reference // 每个传入的观察对象都会存入这个map
checkRetainedExecutor.execute {
moveToRetained(key) // 工作线程中延迟retainedDelayMillis再执行。
}
}
...
// 这个函数被反复调用
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
// 返回值不为null,说明当前这个被观察的对象未发生内存泄漏,故将其从map中移除
watchedObjects.remove(ref.key)
}
} while (ref != null) // 清空队列
}
...
@Synchronized private fun moveToRetained(key: String) {
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
// 由于弱可达(未内存泄漏)的观察对象都已经过removeWeaklyReachableObjects()从watchedObjects
// 移除,故对于当前观察对象,若其在watchedObjects中仍存在,则证明其发生了内存泄漏。
retainedRef.retainedUptimeMillis = clock.uptimeMillis() // 标记该对象内存泄漏了
onObjectRetainedListeners.forEach { it.onObjectRetained() } // 调用发生内存泄漏时的回调
}
}
}

moveToRetained()中最后调用的回调,将调用HeapDumpTrigger.checkRetainedInstances()函数,进行 dump hprof 前的最后检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// HeapDumpTrigger.kt 对应实例InternalLeakCanary.heapDumpTrigger
private fun checkRetainedObjects() {
...

// 统计可能发生了内存泄漏的被观察对象的个数。返回的正是 watchedObjects.count
var retainedReferenceCount = objectWatcher.retainedObjectCount

// 由于没有被回收的原因可能是延迟等待时间不够长, GC 尚未来得及回收,故这里再进行一次 GC
if (retainedReferenceCount > 0) {
gcTrigger.runGc()
retainedReferenceCount = objectWatcher.retainedObjectCount
}

if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

val now = SystemClock.uptimeMillis()
val elapsedSinceLastDumpMillis = now - lastHeapDumpUptimeMillis
// 为避免频繁dump操作,节省资源,两次 dump 之间时间间隔少于阈值则直接返回
if (elapsedSinceLastDumpMillis < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
onRetainInstanceListener.onEvent(DumpHappenedRecently)
showRetainedCountNotification(
objectCount = retainedReferenceCount,
contentText = application.getString(R.string.leak_canary_notification_retained_dump_wait)
)
scheduleRetainedObjectCheck(
delayMillis = WAIT_BETWEEN_HEAP_DUMPS_MILLIS - elapsedSinceLastDumpMillis
)
return
}

dismissRetainedCountNotification()
val visibility = if (applicationVisible) "visible" else "not visible"
// 执行 dump 操作
dumpHeap(
retainedReferenceCount = retainedReferenceCount,
retry = true,
reason = "$retainedReferenceCount retained objects, app is $visibility"
)
}

调用dumpHeap导出并分析 hprof 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private fun dumpHeap(
retainedReferenceCount: Int,
retry: Boolean,
reason: String
) {
// 指定 dump 路径 新建文件
val directoryProvider =
InternalLeakCanary.createLeakDirectoryProvider(InternalLeakCanary.application)
val heapDumpFile = directoryProvider.newHeapDumpFile()

val durationMillis: Long
if (currentEventUniqueId == null) {
currentEventUniqueId = UUID.randomUUID().toString()
}
try {
InternalLeakCanary.sendEvent(DumpingHeap(currentEventUniqueId!!))

if (heapDumpFile == null) {
throw RuntimeException("Could not create heap dump file")
}
saveResourceIdNamesToMemory()
val heapDumpUptimeMillis = SystemClock.uptimeMillis()
KeyedWeakReference.heapDumpUptimeMillis = heapDumpUptimeMillis
durationMillis = measureDurationMillis {
// 关键:执行dump hprof到指定文件
configProvider().heapDumper.dumpHeap(heapDumpFile)

}
if (heapDumpFile.length() == 0L) {
throw RuntimeException("Dumped heap file is 0 byte length")
}
lastDisplayedRetainedObjectCount = 0
lastHeapDumpUptimeMillis = SystemClock.uptimeMillis()
objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)
currentEventUniqueId = UUID.randomUUID().toString()

// 关键:开启 workManager 进行堆分析
InternalLeakCanary.sendEvent(HeapDump(currentEventUniqueId!!, heapDumpFile, durationMillis, reason))

} catch (throwable: Throwable) {
// dump 失败了,重试,再不行就显示通知提示失败
InternalLeakCanary.sendEvent(HeapDumpFailed(currentEventUniqueId!!, throwable, retry))

if (retry) {
scheduleRetainedObjectCheck(
delayMillis = WAIT_AFTER_DUMP_FAILED_MILLIS
)
}
showRetainedCountNotification(
objectCount = retainedReferenceCount,
contentText = application.getString(
R.string.leak_canary_notification_retained_dump_failed
)
)
return
}
}

以上代码执行了两个最为关键的逻辑:导出 hprof 和分析 hprof。

首先看 hprof 的导出操作:

1
2
3
4
// HeapDumpTrigger.kt
durationMillis = measureDurationMillis {
configProvider().heapDumper.dumpHeap(heapDumpFile)
}

这里通过configProvider()拿到了同一个包下的LeakCanary.Config内部类实例,并调用其heapDumper.dumpHeap()函数:

1
2
// LeakCanary.kt
val heapDumper: HeapDumper = AndroidDebugHeapDumper,
1
2
3
4
5
6
// AndroidDebugHeapDumper.kt
object AndroidDebugHeapDumper : HeapDumper {
override fun dumpHeap(heapDumpFile: File) {
Debug.dumpHprofData(heapDumpFile.absolutePath)
}
}

到这里就明确了: leakCanary 导出 hprof 的方式实际上就是调用 Android 系统给我们提供的接口:Debug.dumpHprofData(path : String)

再看 hprof 的分析操作:

1
2
3
4
5
6
7
8
9
// HeapDumpTrigger.kt
InternalLeakCanary.sendEvent(
HeapDump(
currentEventUniqueId!!,
heapDumpFile,
durationMillis,
reason
)
)

LeakCanary 实现了一套自己的简要的消息机制,Event是其预先定义的一些事件,EventListener是其监听到Event时的回调接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// EventListener.kt
fun interface EventListener {

// 同属于Event,但需要各自不同的参数,故使用sealed class
sealed class Event(
val uniqueId: String
) : Serializable {

class DumpingHeap(uniqueId: String) : Event(uniqueId)

// 这里可见以上代码中的HeapDump就是一种 Event
class HeapDump(
uniqueId: String,
val file: File,
val durationMillis: Long,
val reason: String
) : Event(uniqueId)

class HeapDumpFailed(
uniqueId: String,
val exception: Throwable,
val willRetryLater: Boolean
) : Event(uniqueId)

class HeapAnalysisProgress(
uniqueId: String,
val step: Step,
val progressPercent: Double
) : Event(uniqueId)

...
}

fun onEvent(event: Event)
}

再看InteralLeakCanary.sendEvent()这个函数:

1
2
3
4
5
6
// InternalLeakCanary.kt
fun sendEvent(event: Event) {
for(listener in LeakCanary.config.eventListeners) {
listener.onEvent(event)
}
}

其实就是逐个调用每个EventListeneronEvent()函数罢了。再看LeakCanary.config.eventListeners中都注册了哪些监听器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// LeakCanary.kt
val eventListeners: List<EventListener> = listOf(
LogcatEventListener, // 负责在接收到对应事件时打印日志
ToastEventListener, // 负责在接收到对应事件时弹出Toast
LazyForwardingEventListener { // 负责在接收到对应事件时弹出通知
if (InternalLeakCanary.formFactor == TV) TvEventListener else NotificationEventListener
},
// 关键: 创建工作线程,对堆快照文件进行分析。之前版本中使用的是 Service
when {
RemoteWorkManagerHeapAnalyzer.remoteLeakCanaryServiceInClasspath ->
RemoteWorkManagerHeapAnalyzer
WorkManagerHeapAnalyzer.validWorkManagerInClasspath -> WorkManagerHeapAnalyzer
else -> BackgroundThreadHeapAnalyzer
}
),

与堆分析有关的是最后一个listener,这里根据情况分为了更细化的三种 Analyzer ,但它们都实现了EventListener接口,分别对应堆分析的三种运行场所,可由开发者自主选择(此处略过):

  • 位于另一进程的workManager
  • 位于本进程的workManager
  • 位于本进程的一个后台线程中
    虽然执行场所不同,但它们都完成同一件工作:即在工作线程中完成对堆快照文件的分析。那么我们就以WorkManagerHeapAnalyzer为例,分析接下来的流程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// WorkManagerHeapAnalyzer.kt
object WorkManagerHeapAnalyzer : EventListener {

// 只有当我们的app导入了相关依赖后才能确定有workManager可以用,并且对其版本也有要求,
// 故这里只能通过反射动态加载workManager类,供后续使用
internal val validWorkManagerInClasspath by lazy {
try {
Class.forName("androidx.work.WorkManager")
val dataBuilderClass = Class.forName("androidx.work.Data\$Builder")
dataBuilderClass.declaredMethods.any { it.name == "putByteArray" }.apply {
if (!this) {
SharkLog.d { "Could not find androidx.work.Data\$Builder.putByteArray, WorkManager should be at least 2.1.0." }
}
}
} catch (ignored: Throwable) {
false
}
}

...

override fun onEvent(event: Event) {
if (event is HeapDump) {
// HeapAnalyzerWorker继承自Worker
val heapAnalysisRequest = OneTimeWorkRequest.Builder(HeapAnalyzerWorker::class.java).apply {
setInputData(event.asWorkerInputData())
addExpeditedFlag()
}.build()
SharkLog.d { "Enqueuing heap analysis for ${event.file} on WorkManager remote worker" }
val application = InternalLeakCanary.application
// 获取workManager实例并执行worker
WorkManager.getInstance(application).enqueue(heapAnalysisRequest)
}
}
}

可以看到我们通过反射加载WorkManager类。之后在onEvent()中就是常规的workManager的用法了:构建Worker,传入workManager执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HeapAnalyzerWorker.kt
internal class HeapAnalyzerWorker(appContext: Context, workerParams: WorkerParameters) :
Worker(appContext, workerParams) {
override fun doWork(): Result {
// 关键:调用runAnalysisBlocking进行分析
val doneEvent =
AndroidDebugHeapAnalyzer.runAnalysisBlocking(inputData.asEvent()) { event ->
InternalLeakCanary.sendEvent(event)
}
InternalLeakCanary.sendEvent(doneEvent)
return Result.success()
}

...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// AndroidDebugHeapAnalyzer.kt
fun runAnalysisBlocking(
heapDumped: HeapDump,
isCanceled: () -> Boolean = { false },
progressEventListener: (HeapAnalysisProgress) -> Unit
): HeapAnalysisDone<*> {
val progressListener = OnAnalysisProgressListener { step ->
val percent = (step.ordinal * 1.0) / OnAnalysisProgressListener.Step.values().size
progressEventListener(HeapAnalysisProgress(heapDumped.uniqueId, step, percent))
}

val heapDumpFile = heapDumped.file
val heapDumpDurationMillis = heapDumped.durationMillis
val heapDumpReason = heapDumped.reason

val heapAnalysis = if (heapDumpFile.exists()) {
// 使用Shark API 进行堆分析
analyzeHeap(heapDumpFile, progressListener, isCanceled)
} else {
missingFileFailure(heapDumpFile)
}

...省略一大段代码
}

其实到这一步,代码的执行重点就转到调用Shark提供的堆分析工具了。Shark同样出自 Square 之手,在 LeakCanary2 中用以代替之前版本中使用的堆分析工具HAHA。结合注释看Shark的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
private fun analyzeHeap(
heapDumpFile: File,
progressListener: OnAnalysisProgressListener,
isCanceled: () -> Boolean
): HeapAnalysis {
val config = LeakCanary.config
val heapAnalyzer = HeapAnalyzer(progressListener)
val proguardMappingReader = try {
ProguardMappingReader(application.assets.open(PROGUARD_MAPPING_FILE_NAME))
} catch (e: IOException) {
null
}

progressListener.onAnalysisProgress(PARSING_HEAP_DUMP)

val sourceProvider =
ConstantMemoryMetricsDualSourceProvider(ThrowingCancelableFileSourceProvider(heapDumpFile) {
if (isCanceled()) {
throw RuntimeException("Analysis canceled")
}
})

// Shark API openHeapGraph将解析指定hprof文件并返回一个描述其结构的图(graph)
val closeableGraph = try {
sourceProvider.openHeapGraph(proguardMapping = proguardMappingReader?.readProguardMapping())
} catch (throwable: Throwable) {
return HeapAnalysisFailure(
heapDumpFile = heapDumpFile,
createdAtTimeMillis = System.currentTimeMillis(),
analysisDurationMillis = 0,
exception = HeapAnalysisException(throwable)
)
}
return closeableGraph
.use { graph ->
// 向heapAnalyzer.analyze函数传入graph进行分析,结果包装为result
val result = heapAnalyzer.analyze(
heapDumpFile = heapDumpFile,
graph = graph,
leakingObjectFinder = config.leakingObjectFinder,
referenceMatchers = config.referenceMatchers,
computeRetainedHeapSize = config.computeRetainedHeapSize,
objectInspectors = config.objectInspectors,
metadataExtractor = config.metadataExtractor
)
if (result is HeapAnalysisSuccess) {
val lruCacheStats = (graph as HprofHeapGraph).lruCacheStats()
val randomAccessStats =
"RandomAccess[" +
"bytes=${sourceProvider.randomAccessByteReads}," +
"reads=${sourceProvider.randomAccessReadCount}," +
"travel=${sourceProvider.randomAccessByteTravel}," +
"range=${sourceProvider.byteTravelRange}," +
"size=${heapDumpFile.length()}" +
"]"
val stats = "$lruCacheStats $randomAccessStats"
result.copy(metadata = result.metadata + ("Stats" to stats))
} else result
}
}
定位泄漏对象

ClosableHeapGraph就是实现了closable接口的HeapGraph子接口,故我们分析HeapGraph的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
interface HeapGraph {
val identifierByteSize: Int

val context: GraphContext

val objectCount: Int

val classCount: Int

val instanceCount: Int

val objectArrayCount: Int

val primitiveArrayCount: Int

val gcRoots: List<GcRoot>

//所有对象的序列,包括类对象、实例对象、对象数组、原始类型数组
val objects: Sequence<HeapObject>

//类对象序列
val classes: Sequence<HeapClass>

//实例对象数组
val instances: Sequence<HeapInstance>

//对象数组序列
val objectArrays: Sequence<HeapObjectArray>

//原始类型数组序列
val primitiveArrays: Sequence<HeapPrimitiveArray>

@Throws(IllegalArgumentException::class)
fun findObjectById(objectId: Long): HeapObject

@Throws(IllegalArgumentException::class)
fun findObjectByIndex(objectIndex: Int): HeapObject

fun findObjectByIdOrNull(objectId: Long): HeapObject?

// 关键
fun findClassByName(className: String): HeapClass?

fun objectExists(objectId: Long): Boolean

fun findHeapDumpIndex(objectId: Long): Int

fun findObjectByHeapDumpIndex(heapDumpIndex: Int): HeapObject
}

可以看到HeapGraph可以解析出该堆快照的信息,包括各种对象,并提供索引方法访问。那么,我们便有方法探测堆中发生内存泄漏的对象的位置了:

  1. 首先根据上文分析,可知此时目标对象被com.squareup.leakcanary.KeyedWeakReference所持有,故可以用findClassByName()方法传入全限定名找到这个类;
  2. 解析这个类的实例域,找到字段名和引用对象的ID,再用findObjectById()方法就能定位到目标对象了。

Shark在解析得到heapGraph的背后封装了大量逻辑,使得堆分析十分简便易用。是个好库,感恩 Square!

确定泄漏引用链

在之前 LeakCanary2 的简单使用示例中,我们可以看到最终在 app 里显示了从 GC Roots 对象到内存泄漏对象的引用链,这一步便是确定这条引用链。由于到内存泄漏对象可能存在多条引用链,故Shark选择 BFS 找出最短引用链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private fun State.findPathsFromGcRoots(): PathFindingResults {
// 首先让 GCRoots 对象全部入队
enqueueGcRoots()

val shortestPathsToLeakingObjects = mutableListOf<ReferencePathNode>()
visitingQueue@ while (queuesNotEmpty) {
val node = poll() // 队列中对象依次出队

// 如果是内存泄漏的对象,则记录下来,否则继续
if (leakingObjectIds.contains(node.objectId)) {
shortestPathsToLeakingObjects.add(node) // 存储发生了内存泄漏的对象,用于反推引用链

// 判断已经找到的内存泄漏的对象个数,如果已经找完了则提前结束搜索
if (shortestPathsToLeakingObjects.size == leakingObjectIds.size()) {
if (computeRetainedHeapSize) {
listener.onAnalysisProgress(FINDING_DOMINATORS)
} else {
break@visitingQueue
}
}
}

// 解析对象
val heapObject = graph.findObjectById(node.objectId)
objectReferenceReader.read(heapObject).forEach { reference ->
// 解析邻居 邻居入队
val newNode = ChildNode(
objectId = reference.valueObjectId,
parent = node,
lazyDetailsResolver = reference.lazyDetailsResolver
)
enqueue(newNode, isLowPriority = reference.isLowPriority)
}
}

// 路径解析通过反推链表完成,这里不再赘述
return PathFindingResults(
shortestPathsToLeakingObjects,
if (visitTracker is Dominated) visitTracker.dominatorTree else null
)
}

至此, LeakCanary 检测内存泄漏的主要流程便走完了。我们了解了从 app 启动开始,contentProvider注册监视器,到检测到内存泄漏,触发回调,产生 hprof 文件,解析堆结构,到计算引用链为止 leakCanary 的工作原理,再后续的工作只剩下 UI 层的显示,以及和 LeakCanary app 的 IPC 通信,不再属于我们今天讲解的重点,便不再赘述,有兴趣的同学可以自己下载 LeakCanary2 源码学习。

总结

  • 利用弱引用和引用队列的特性捕获内存泄漏;
  • 利用WorkManager(动态获取)执行后台任务;
  • 利用Shark解析堆快照文件;
  • 利用广度优先搜索确定引用链。

经常能在网上看到开发者争吵对于Android开发,究竟什么样的架构才是最好的。MVC 早已过时,MVP 在现代compose框架下很别扭,所以争论的焦点往往是 MVVM VS MVI

明确概念区分是有必要的,但我觉得实在是没必要把它当成非黑即白的大事。看过许多开源项目,在 Android + Compose 语境里,所谓 MVVM 和 MVI,实际落地后确实只差一层皮:

现在大家对于状态管理的普遍写法是:

  • ViewModel 持有 StateFlow
  • Compose collect
  • UI 调 viewModel.xxx()

这时如果有人嘴上说自己是 MVI,但代码只是:

  • 多定义了个 Intent
  • 多定义了个 Effect
  • onClick 改成 sendEvent(intent)
  • 内部拿when识别接着走不同逻辑

那在我看来,这只是MVVM 套了 MVI 命名风格,不是什么本质区别,这样的MVI实际上根本不足以称之为MVI。MVI 与 MVVM 的真正区别,不在于有没有 Intent / Effect 类型,而在于是否真的在代码中采用了两种不同的状态管理哲学。对于MVI来说:

  • 是否有 reducer 思维?
  • 是否有严格的单向状态演化约束?
  • 是否真正分离了状态和副作用?

在 MVVM 中,ViewModel 是页面逻辑中心,对外暴露状态,并直接提供若干命令式方法给 UI 调用,状态更新逻辑也散落在各方式中,相对自由奔放。例如:

1
2
3
4
fun onRefresh() { ... }
fun onRetry() { ... }
fun onKeywordChange(value: String) { ... }
fun onItemClick(id: String) { ... }

这使得业务代码写起来很灵活,直接新增方法就好了,逻辑都写方法里,状态有变化直接在这里update就好了,UI按需调用。代价则是基本没有对状态的任何范式约束。

而在 MVI 中,所有输入先被建模成 Intent;所有页面可见信息收敛成单一 State;状态变化应只通过 reducer 统一规则演化,并且明确隔离副作用,派生出Effect。也就是说,MVI 的重点不是“多定义几个 sealed class”,而是:

  • 状态转移是否被统一建模
  • 所有用户动作和系统动作是否都走统一入口
  • 副作用是否被明确区分,不混进 state

所以说,很多人把区别理解成:MVVM就是UI调用 onClick(),MVI就是UI调用 sendEvent(Intent.Click),这太浅了,也根本不是核心。如果还纠结于类似方法名怎么起、sealed class 要不要写、 一次性事件叫 effect 还是 event 这类价值不高的争论上,实属可惜。真正的区别更接近于:MVVM把ViewModel用来当页面管理者,功能自己设计;而MVI则是把ViewModel当成一个出入口,统一输入输出,内部维护一个状态机,所有输入都驱动状态机的转移,输出新状态。

也因此在我看来,虽然这两种架构经常被放在一起对比,但实际上他们所反映的思路并非同一维度:

  • MVVM 是一种更强调对架构的严格分层与View/Model解耦
  • MVI 则是在此基础之上,强调状态流转的建模思路

严格讲,很多 Android 项目其实就是MVVM 外壳 + MVI 风格状态管理

  • MVVM解决架构分层:View层对应Composable,ViewModel层就是ViewModel,Model层对应依赖的Repository及其下的数据源;
  • MVI解决页面状态流转与事件消费:用 Intent / State / Effect / Reducer 的方式组织。

所以在我看来它们并不完全互斥,很多成熟写法其实就是架构层面是 MVVM 而状态管理层面借鉴 MVI。过度区分是没有必要的,不应为了比较而比较,谁优谁劣更是没有定数。

在 Java / JVM Kotlin 并发开发中,ThreadLocal 经常被用来保存“与一次请求/一次任务绑定的上下文信息”,比如 traceId、用户信息、租户信息、事务/会话上下文等。它看起来像“全局变量”,但又能做到线程隔离:同一个 ThreadLocal 在不同线程里互不干扰


1. ThreadLocal 是什么

ThreadLocal 说到底也就是 Java 语言提供的一个泛型类,它的主要作用是为变量包上一层壳:正常来说,当多线程访问同一变量时,他们将持有相同的变量,访问同一个地址,从而也引入线程不安全的并发问题;当如果将这个变量包裹在 ThreadLocal 中,每个线程则将只会访问到各线程独立的、针对该变量的一份拷贝,实现变量的线程隔离。

核心特点:

  • 变量是“线程作用域”(thread-scoped),不是“对象作用域”
  • set/get/remove 都是对“当前线程”的数据操作
  • 很适合承载上下文类信息,减少层层传参

2. 用法示例

1
2
3
4
5
6
7
8
9
10
11
12
13
import kotlin.concurrent.thread  

val name = ThreadLocal<String>()

thread {
name.set("hello")
println("thread-1 name: ${name.get()}")
}.join()

thread {
name.set("world")
println("thread-2-name: ${name.get()}")
}.join()

以上代码创建了一个ThreadLocal<String>,并在两个线程中为其设置了不同的值然后读取。输出结果为:

1
2
thread-1 name: hello
thread-2-name: world

3. 原理

很多人初看会以为 ThreadLocal 自己保存了“每个线程的值”,所有逻辑都在这个类中,包括网上很多过时的博客都会如此误导,说TheadLocal里放了张 Map,key 是 Thread 对象,value 是线程给变量赋的值。 实际上并非如此,ThradLocal 的源码轻量且直观,自己读一遍就能完全掌握其原理。

实际上,值不在 ThreadLocal 里,而在 Thread 里,存放在另一个数据结构:一个字段名为threadLocalsThreadLocalMap中;ThreadLocal 只是作为 ThreadLocalMap 这张表的 key。

  • 每个 Thread 对象内部维护两张ThreadLocalMap表:
    • threadLocals:普通 ThreadLocal 的存储
    • inheritableThreadLocalsInheritableThreadLocal 的存储(稍后介绍)
  • ThreadLocal 更像是一把“钥匙”(key),用于在当前线程的表里定位 value。
1
2
3
// java.lang.Thread 中的成员变量
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

3.1. ThreadLocalMap 与弱引用 Entry

ThreadLocalMapThreadLocal 的内部静态类,底层不是 HashMap,而是一个数组:

  • Entry[] table 存放键值对
  • 使用开放定址 + 线性探测 解决冲突
1
2
3
4
5
6
7
8
9
// ThreadLocalMap lazy 构造方法
// Entry 为其底层存储数据的成员数组
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

最关键的是 Entry

  • key 是 ThreadLocal 的弱引用(WeakReference)
  • value 用一个强引用另外存储
1
2
3
4
5
6
7
8
9
static class Entry extends WeakReference<ThreadLocal<?>> {  
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

⚠️注意!
这带来一个非常重要的内存泄漏隐患,在使用时需要注意: 如果 ThreadLocal 本身不再被外部强引用,GC 可能回收它使得 key 变成 null (弱引用特性),但 value 仍可能被强引用挂在表里,直到表在后续操作中被清理或线程结束。


3.2. get() 做了什么:从当前线程取表再查 key

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {  
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

方法总结:

  1. 获取当前所在线程对象:Thread t = Thread.currentThread()
  2. 获取当前线程的 ThreadLocalMap 对象:ThreadLocalMap m = t.threadLocals
  3. 若表还未初始化: m != null,在 map 中按当前 ThreadLocal 查找 entry,命中则返回 value
  4. 否则(或未命中):走初始化逻辑 setInitialValue()

初始化值来源:

  • 默认 initialValue() 返回 null
  • 可通过覆写 initialValue()ThreadLocal.withInitial(supplier) 提供初始值

3.3. set() 做了什么:在当前线程的 ThreadLocalMap 里放值

1
2
3
4
5
6
7
8
9
10
11
12
13
public void set(T value) {  
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

方法总结:

  1. 拿当前线程 t
  2. 取线程的ThreadLocalMap,没有就创建
  3. 调用表的set方法,在 ThreadLocalMap 中插入或覆盖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private void set(ThreadLocal<?> key, Object value) {  
// 计算哈希
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

// 线性探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {

// 1. 同key,直接覆盖
if (e.refersTo(key)) {
e.value = value;
return;
}

// 2. 旧entry,已被GC回收,直接插入/替换
if (e.refersTo(null)) {
replaceStaleEntry(key, value, i);
return;
}
}

// 3. 空槽,直接插入
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

方法总结:

  • 计算 key 的哈希定位到数组下标i
  • 如遇冲突,线性探测向后找
  • 遇到三种情况:
    1. 空槽:直接插入
    2. 同 key:覆盖 value
    3. 陈旧 entry(stale entry,key 已被 GC 成 null):触发替换/清理逻辑(例如 replaceStaleEntry),尽量把 stale 清掉并维持探测链正确

3.4. remove() 做了什么:将其从当前线程表中删除 + 触发重排

ThreadLocal.remove() 不是“把 ThreadLocal 清空”,而是从 Thread.currentThread().threadLocals 中移除该 key 对应的 entry。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
private void remove(ThreadLocal<?> key) {  
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.refersTo(key)) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

...

private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale. while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

方法总结:

  • 在开放定址结构里删除元素会“断链”,所以实现通常会:
    • 清空当前槽位
    • 调用 expungeStaleEntry(...) 的逻辑做重排(rehash/expunge),确保后续 key 仍可被正确查到

4. 线程池“串号”与“疑似内存泄漏”:问题解释

4.1 为什么会串号

线程池会复用线程,而若上一个任务 set() 了 ThreadLocal 但没 remove(),下一个任务复用同一线程时 get() 会读到上次残留的值,这就是常见的“上下文串号”问题。

4.2 为什么会出现“疑似内存泄漏”

根因与引用类型和JVM回收机制有关:

  • Entry.key 是弱引用,ThreadLocal 可被 GC 回收,导致key 变 null(过期)
  • Entry.value 是强引用,仍挂在 ThreadLocalMap.table
  • 当线程池线程生命周期很长,value 可能长期释放不了

所以严格说这更像是value 的滞留,而不是语言层面的永久泄漏;但在服务型线程池里表现就像泄漏一样严重。

4.3 为什么有时又会“自己恢复”

ThreadLocalMap 并非完全不管 stale entry。它会在 get/set/remove 等路径中顺便清理,这一点在上面的源码中也已经能观察到。常见的清理方法有:

  • expungeStaleEntry(i):从某位置开始清理并重排探测链
  • cleanSomeSlots(...):启发式清理一段,控制均摊开销
  • expungeStaleEntries():更彻底的全表清理(更重)

这种清理是“被动触发”的。若线程后续很少触达 map,滞留可能持续很久。

4.4. 最佳实践

因此,对于线程池/容器线程(Web 线程、RPC 线程、异步执行器线程),一定要确保对ThreadLocal在用后调用 try/finally remove,解决上下文串号(逻辑错误)和 value 滞留(内存风险):

1
2
3
4
5
6
try {
TL.set(ctx);
// do work
} finally {
TL.remove();
}

5. 关于 InheritableThreadLocal

因为ThreadLocal值是存在Thread专属的表中的,这就导致在一些异步场景中,ThreadLocal值无法随着线程切换进行自动传递。对于跨线程传递ThreadLocal值的问题,JDK 原生提供的方案就是InheritableThreadLocal,直译正是“可继承的 ThreadLocal”。

InheritableThreadLocal 的表对应于 Thread.inheritableThreadLocals。子线程在创建时会把父线程相关 map 做拷贝/派生。

1
2
3
4
// Thread 构造方法中的相关逻辑摘取
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

这种方案的缺陷在于,它仅是在线程创建时进行复制,是一次性的;而在线程池中,线程通常不是“新建”,而是复用,因此它经常不符合对“父子线程传递”的直觉预期,个人用的也比较少。


总结

  • ThreadLocal 的隔离不是“ThreadLocal 内部隔离”,而是 “每个线程各自持有一张 ThreadLocalMap”
  • ThreadLocalMap 通过数组 + 线性探测实现,key 为弱引用、value 为强引用
  • 线程池场景中,不 remove() 会导致:
    • 业务层面“串号”
    • 内存层面 value 滞留(看起来像泄漏)
  • 源码通过在 get/set/remove 中顺带清理 stale entry 来做均摊控制,但不可靠替代显式清理
  • 最佳实践:ThreadLocal 用完必 remove,且放 finally

本文从 Kotlin 的基本类型出发,深入探讨数值字面量的实现原理,并全面剖析 Kotlin 与 Java 中的装箱/拆箱机制,力求做到知其然知其所以然。


一、Kotlin 基本类型概览

Kotlin 作为一门现代 JVM 语言,对基本类型的处理既保留了 Java 的高效性,又增添了更优雅的语法设计。与 Java 区分原始类型和包装类的理念不同,Kotlin 中一切皆对象——没有原始类型(primitive types)的概念,但编译器会在底层自动优化为 JVM 原始类型以保证性能。

1.1 数值类型

Kotlin 提供了多种数值类型,覆盖不同精度需求:

类型 位宽 取值范围
Byte 8 位 -128 ~ 127
Short 16 位 -32768 ~ 32767
Int 32 位 $-2^{31}$ ~ 2^{31}-1
Long 64 位 $-2^{63}$ ~ $2^{63}-1$
Float 32 位 单精度浮点
Double 64 位 双精度浮点
注:Byte, Short, Int, Long 还具有对应无符号类型:UByte, UShort, UInt, ULong

字面量写法:

1
2
3
4
5
6
7
val intNum = 100          // 默认推断为 Int
val longNum = 100L // L 后缀表示 Long
val floatNum = 3.14f // f 后缀表示 Float
val doubleNum = 3.14 // 默认推断为 Double
val hexNum = 0xFF // 十六进制
val binaryNum = 0b1010 // 二进制
val readable = 1_000_000 // 支持下划线分隔,提升可读性

显式类型转换: ⚠️Kotlin 不支持隐式类型转换,必须显式调用转换函数:

1
2
3
val i: Int = 100
val l: Long = i.toLong() // ✅ 正确
// val l: Long = i // ❌ 编译错误

1.1.1 Number 类

以上数值类型都有一个共同的父类:Number,这是一个抽象类,并定义了一系列显示类型转换函数:

1
2
3
4
5
6
7
8
9
public abstract class Number {  
public abstract fun toDouble(): Double
public abstract fun toFloat(): Float
public abstract fun toLong(): Long
public abstract fun toInt(): Int
public abstract fun toChar(): Char // Deprecated
public abstract fun toShort(): Short
public abstract fun toByte(): Byte
}

通过继承 Number类并实现 Comparable接口,派生出以上数值类型。

1.2 字符与字符串

Char 表示单个字符(16-bit Unicode),用单引号包裹;字符串用双引号或三引号表示:

1
2
3
4
5
6
val letter: Char = 'A'
val str = "Hello, Kotlin"
val multiLine = """
这是多行字符串
保留原始格式
""".trimIndent()

注意 Char 与数值类型互转的方法,直接调用 toChar()toLong()等已经弃用,需要以 Int类型作为媒介:

1
2
3
4
5
val char: Char = 'A'  
val i: Int = char.code // Code of a Char is the value it was constructed with, and the UTF-16 code unit corresponding to this Char.
val l: Long = char.code.toLong()
val c: Char = i.toChar() // significant 16 bits of this Int value.
val d: Char = i.digitToChar() // decimal digit

Kotlin 支持字符串模板,这在构造特定字符串以及日志打印等场景都非常好用:

1
2
3
val name = "World"
println("Hello, $name!") // 简单变量
println("长度是 ${name.length}") // 表达式

Kotlin String 还有更多值得说道的知识点,在此先略过,之后另写一篇博客来详细探讨。

1.3 布尔类型与数组

1
2
3
4
5
6
7
val isKotlinFun: Boolean = true

// 泛型数组
val arr = arrayOf(1, 2, 3)

// 原始类型数组(避免装箱开销)
val intArr = intArrayOf(1, 2, 3)

二、数值字面量的实现机制

既然 Kotlin 中 Int 是一个类,为什么 val x = 1 不需要写成 val x = Int(1) 这样的构造函数形式?

2.1 字面量是语言级别的语法糖

数值字面量是 Kotlin 语言规范直接支持的特殊语法,编译器看到 1 时,直接将其识别为 Int 类型的字面量常量,不需要经过任何构造函数调用。

1
2
val x = 1        // 字面量语法,编译器直接处理
val y = Int(1) // ❌ 编译错误!Int 没有公开构造函数

2.2 为什么 Int 没有构造函数?

Kotlin 的数值类型是特殊的内置类型,构造函数是私有的:

设计原因 说明
性能优化 编译器可以直接映射到 JVM 原始类型,无需真正的对象分配
语义清晰 1 就是 1,不需要 new Integer(1) 的冗余
防止滥用 避免 Int(someString) 这种容易出错的用法

2.3 编译过程示例

1
2
3
4
5
6
7
8
┌─────────────────────────────────────────────────────┐
│ Kotlin 源码 val x = 1 │
├─────────────────────────────────────────────────────┤
│ 编译器理解 x 是 Int 类型,值为字面量 1 │
├─────────────────────────────────────────────────────┤
│ 字节码输出 ICONST_1 (JVM 原始 int 指令) │
│ ISTORE x │
└─────────────────────────────────────────────────────┘

反编译为 Java 代码,就是简单的 int x = 1;——直接是原始类型,零开销。


三、Kotlin 中的装箱机制

虽然 Kotlin 在语法层面”隐藏”了装箱的概念,但装箱并不是就消失了——只是编译器帮我们自动处理了。

3.1 什么时候会发生装箱?

场景 是否装箱 底层类型
val x: Int = 1 ❌ 不装箱 JVM int
val x: Int? = 1 ✅ 装箱 JVM Integer
val list: List<Int> ✅ 装箱 List<Integer>
fun foo(x: Any) 传入 Int ✅ 装箱 Object
val arr: IntArray ❌ 不装箱 JVM int[]
val arr: Array<Int> ✅ 装箱 JVM Integer[]

3.2 可空类型必须装箱

JVM 原始类型无法表示 null,所以可空数值必须用包装类:

1
2
val a: Int = 1      // 底层: int
val b: Int? = 1 // 底层: Integer(才能存 null)

3.3 泛型强制装箱

JVM 的泛型使用类型擦除,不支持原始类型作为类型参数:

1
2
val list = listOf(1, 2, 3)  // List<Int> → List<Integer>
// 每个元素都被装箱了!

3.4 Array<Int> vs IntArray

1
2
3
4
5
6
7
// 装箱数组
val boxed: Array<Int> = arrayOf(1, 2, 3)
// 底层: Integer[] {Integer.valueOf(1), Integer.valueOf(2), ...}

// 原始类型数组
val primitive: IntArray = intArrayOf(1, 2, 3)
// 底层: int[] {1, 2, 3}

内存布局差异:

1
2
3
4
5
6
7
Array<Int> (装箱)                    IntArray (原始)
┌─────────────────┐ ┌─────────────────┐
│ Integer 引用 ──────→ [Integer 对象] │ int: 1 │
│ Integer 引用 ──────→ [Integer 对象] │ int: 2 │
│ Integer 引用 ──────→ [Integer 对象] │ int: 3 │
└─────────────────┘ └─────────────────┘
额外的对象开销 + 指针跳转 连续内存,零开销

所以多使用诸如 IntArray的原始数据类型数组可以避免装箱开销,算是 Kotlin 使用的基本技巧。


四、Java 中的装箱机制

Java 的装箱机制比 Kotlin 更”显眼”——因为 Java 明确区分原始类型和包装类

4.1 原始类型 vs 包装类

原始类型 包装类
int Integer
long Long
double Double
boolean Boolean

4.2 自动装箱与拆箱

Java 5 引入了自动装箱,让两种类型可以”无缝”转换:

1
2
3
4
5
// 自动装箱:int → Integer
Integer x = 100; // 编译器转换为: Integer.valueOf(100)

// 自动拆箱:Integer → int
int y = x; // 编译器转换为: x.intValue()

4.3 装箱的经典陷阱

陷阱一:== 比较的诡异行为(面试常考)

1
2
3
4
5
6
7
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true ✅

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false ❌

原因是 Integer.valueOf() 对 -128 ~ 127 范围内的值使用了缓存池,超出范围则创建新对象。比较值永远用 equals()

1
2
3
4
5
6
public static Integer valueOf(int i) {
if (i >= -128 && i <= 127) {
return IntegerCache.cache[i + 128]; // 返回缓存对象
}
return new Integer(i); // 超出范围,创建新对象
}

陷阱二:NullPointerException

1
2
3
Integer x = null;
int y = x; // 💥 NullPointerException!
// 因为实际执行的是: x.intValue()

陷阱三:循环中的性能杀手

1
2
3
4
5
6
7
8
9
10
11
// 糟糕的写法:每次迭代都装箱
Long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i; // 拆箱 → 相加 → 装箱,创建大量临时对象
}

// ✅ 正确的写法
long sum = 0L;
for (long i = 0; i < 1000000; i++) {
sum += i; // 纯原始类型操作
}

4.4 内存开销对比

  • 原始类型 int: 4 bytes
  • 包装类 Integer: 对象头 (12 bytes) + value (4 bytes) + 对齐 ≈ 16 bytes + 引用指针 4-8 bytes

可见一个 Integer 比 int 多占用约 4-5 倍内存!


五、Kotlin vs Java 装箱对比

特性 Java Kotlin
类型区分 显式区分 int / Integer 统一为 Int,编译器决定
装箱语法 可见 (Integer x = 1) 隐藏 (val x: Int? = 1)
null 安全 运行时抛异常 编译期检查
原始数组 int[] IntArray
包装数组 Integer[] Array<Int>
1
2
3
// Java:你必须自己选择
int a = 1; // 原始类型
Integer b = 1; // 包装类(自动装箱)
1
2
3
// Kotlin:编译器帮你选择
val a: Int = 1 // 底层是 int
val b: Int? = 1 // 底层是 Integer(因为可空)

六、最佳实践

Kotlin 最佳实践

1
2
3
4
5
6
7
8
// 1. 尽量用非空类型
val x: Int = 1 // ✅ 底层是 int

// 2. 数值数组用原始类型版本
val arr = IntArray(1000) // ✅ int[]
val arr = Array<Int>(1000){0} // ❌ Integer[]

// 3. 性能敏感场景注意泛型集合的装箱开销

Java 最佳实践

1
2
3
4
5
6
7
8
9
10
// 1. 优先使用原始类型
int count = 0;

// 2. 比较包装类用 equals()
if (integer1.equals(integer2)) { ... }

// 3. 拆箱前检查 null
if (value != null && value > 0) { ... }

// 4. 避免在循环中使用包装类做累加

七、总结

理解类型系统和装箱机制,是写出高质量 JVM 代码的基础:

  • Kotlin 通过统一的类型语法和空安全设计,将装箱的复杂性隐藏在编译器背后,让开发者专注于业务逻辑
  • Java 的显式双轨制要求开发者时刻意识到原始类型和包装类的区别,避免性能陷阱和 NPE

无论使用哪种语言,记住一个原则即可:在性能敏感的场景下,优先使用原始类型;在需要对象语义的场景下,注意装箱的开销和空值处理

案例一

2025年12月18日,发现v1.0.3版本桌面端安装包体积爆炸增大,从原本的150MB左右暴涨到1GB,如下图所示。
image.png

由于三端均产生该问题,以 macOS 端(.dmg)为例展开排查。解包产物,切入 contents 目录查看文件夹大小,执行 du -sh ./*,结果如下:

1
2
3
4
5
6
7
116K ./Contents/_CodeSignature 
999M ./Contents/app
4.0K ./Contents/Info.plist
196K ./Contents/MacOS
4.0K ./Contents/PkgInfo
16K ./Contents/Resources
158M ./Contents/runtime

显然 Contents/app(999MB) 炸了。这也就是说:这次膨胀原因主要来自 应用层打包进去的 jar/依赖/资源 异常地多,初步怀疑是某个库发生了问题,将三份native包全都打包到一个native平台上了。

切入 Contents/app 执行 du -sh ./* ,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
22M ./opencv-4.6.0-1.5.8-android-x86-b38c986c415ad7d34111fa1ff684a19a.jar
26M ./opencv-4.6.0-1.5.8-ios-arm64-5a95a9fd33b458cf8dc8538fff16.jar
28M ./opencv-4.6.0-1.5.8-ios-x86_64-d3cdf6cb93ba33f2240a97fd5193.jar
24M ./opencv-4.6.0-1.5.8-linux-arm64-91b0d28db0dcbe847ead233f99a2831.jar
22M ./opencv-4.6.0-1.5.8-linux-armhf-4d45d47f293943abde4163761b744fc.jar
27M ./opencv-4.6.0-1.5.8-linux-ppc64le-511dfd3698bebebd724d4052e6a34b90.jar
27M ./opencv-4.6.0-1.5.8-linux-x86_64-91c04431ec13e27014c25b8f77ece6.jar
28M ./opencv-4.6.0-1.5.8-linux-x86-941136bbfad2956a86b10f9242fad34.jar
20M ./opencv-4.6.0-1.5.8-macosx-arm64-699b23fa8d64ced8f96a65d012ff48.jar
25M ./opencv-4.6.0-1.5.8-macosx-x86_64-855f22d26a3e7eb65122adacf1e5cee4.jar
29M ./opencv-4.6.0-1.5.8-windows-x86_64-149da917d80e13db6cac72fd53170cc.jar
27M ./opencv-4.6.0-1.5.8-windows-x86-e136927b1d9446f968ea2bd45dfa13e.jar
...

这就很显然了,验证了猜想是对的。明明是macOS平台的包,却连 windows 及 linux 的jar都被打包进来,所以导致了包体积暴涨。下一步是排查最终的罪魁祸首,是谁干了这件蠢事。回到项目中运行 ./gradlew :composeApp:dependencyInsight --configuration desktopRuntimeClasspath --dependency javacv-platform,输出如下:

image-1.png

水落石出,结果显示是 cmp-image-pick-n-crop-jvm:1.1.2 这个库依赖 org.bytedeco:javacv-platform:1.5.8 又依赖 org.bytedeco:opencv-platform:4.6.0-1.5.8cmp-image-pick-n-crop 是我在Github上找的一个用来裁剪图片的第三方库,它的JVM平台包依赖了 *-platform 全家桶,导致暴增。我在本次更新的改动中将其版本从 1.1.1 升级到 1.1.2 才出现这个问题,看来是作者在更新他的库依赖时犯了个低级错误。

修复很简单,回退到 1.1.1 即可解决,但是治标不治本。本着开源精神至上的原则我把该库fork并拉了下来准备直接修并提PR,结果这才发现这是个假开源项目。源代码是空的,只有个示例app。对印度人的刻板印象再次加深。无奈只好提了个issue作罢,但估计也不会被理睬,看commit日期这个库已经被放弃维护了,令人感叹。

案例二

2026年1月28日,发现v1.0.4版本桌面端安装包体积再次出现异常增大,但这次涨幅不如上次,且只有 Win / Linux 平台受影响。

image-2.png

这次以 Linux 平台 (.deb) 为例进行排查,还是老方法,解包然后看谁变大了。分别对新老版本安装包运行 du -h -d 4 old | sort -h | tail -n 30 查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# old
4.0K old/opt/bandoristationm/share
4.0K old/opt/bandoristationm/share/doc
20K old/opt/bandoristationm/bin
129M old/opt/bandoristationm/lib/app
168M old/opt/bandoristationm/lib/runtime
298M old
298M old/opt
298M old/opt/bandoristationm
298M old/opt/bandoristationm/lib

# new
4.0K new/opt/bandoristationm/share
4.0K new/opt/bandoristationm/share/doc
24K new/opt/bandoristationm/bin
129M new/opt/bandoristationm/lib/app
480M new/opt/bandoristationm/lib/runtime
610M new
610M new/opt
610M new/opt/bandoristationm
610M new/opt/bandoristationm/lib

lib/runtime 炸了,显然这次不是依赖包的问题,而是JRE出了差错,就像没被裁剪一样。在新版本包中运行 find new/opt/bandoristationm/lib/runtime/lib -type f -print0 | xargs -0 ls -lnS | head -n 30,结果如下:

1
2
3
4
5
6
-rw-r--r--  1 501  20  209861688 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/libcef.so
-rw-r--r-- 1 501 20 147634191 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/modules
-rw-r--r-- 1 501 20 26726528 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/server/libjvm.so
-rw-r--r-- 1 501 20 10717392 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/icudtl.dat
-rw-r--r-- 1 501 20 10715134 Jan 28 17:05 new/opt/bandoristationm/lib/runtime/lib/ct.sym
...

一上来就看懂了,怎么混进来个庞然大物 libcef.so,我这是不知道被谁塞了整套 Chromium 啊。翻了翻过去的 commit 记录,定位了罪魁祸首:在 Android Studio 建议迁移 gradle 后,gradle 在升级到9.0.0的同时,顺手给我塞了个 gradle-daemon-jvm.properties。我没有在意,但是这个配置文件里面设置了 toolchainVendor=JETBRAINS,即指定 JBR 为 Gradle Daemon 的运行时,而上面的URL里面又指定了 JBR 的版本是带 JCEF 的版本:

  • Linux: jbrsdk_jcef-21.0.9-linux-x64-b895.149.tar.gz
  • Windows: jbrsdk_jcef-21.0.9-windows-x64-b895.149.zip
  • macOS: jbrsdk_jcef-21.0.9-osx-aarch64-b895.149.tar.gz

这也导致当workflow在跑时,Gradle构建时,不会再用我指定的 temurin,而是现场下带JCEF 的 JBR 用,自然打包时把 libcef.so 等一系列不需要的东西也拐进来了。

解决:直接rollback即可。

经验总结

遇到包体积暴涨不用慌,凡事皆有迹可循。桌面端打包(Compose Desktop/jpackage)有个特点:最终体积主要由两部分决定

  • 应用层lib/app(jar、资源、代码)-> 通常增长是渐进的
  • 运行时层lib/runtime(JRE/JBR、native、资源包)-> 一旦策略变了就是断崖式增长

不要用“总大小”当指标,而是要抓“结构”,把体积拆成可解释的结构指标,迅速归因:

  • app 变大 → 依赖/资源/重复打包
  • runtime 变大 → JDK/JBR 变更、jlink 失效、引入 JCEF、debug 符号等

Kotlin 的 [] 到底是什么

刚写 Kotlin 时,我就很快爱上了 map["key"]list[i] 这种简洁的 [] 写法。它看起来就像在其他语言中早已熟悉的“下标访问”,但更准确的说法是:[] 是编译器提供的运算符约定(operator convention)语法糖。它既能访问 List/Array,也能用于 Map,甚至可以被自定义到任何类型上。


1) [] 的原理:编译期改写为 get/set

Kotlin 的 [] 并不是某种“运行时特性”。编译器会把它静态地改写成函数调用:

  • 读取:a[b]a.get(b)
  • 写入:a[b] = ca.set(b, c)

只要你的类型(成员函数或扩展函数)提供了相应签名,并用 operator 修饰,就可以使用 []。一个简单的自定义类实现例子如下:

1
2
3
4
5
6
7
8
9
10
class Grid<T>(private val data: Array<Array<T>>) {
operator fun get(x: Int, y: Int): T = data[x][y]
operator fun set(x: Int, y: Int, v: T) { data[x][y] = v }
}

fun main() {
val g = Grid(arrayOf(arrayOf(1, 2), arrayOf(3, 4)))
println(g[1, 0]) // 等价于 g.get(1, 0)
g[0, 1] = 9 // 等价于 g.set(0, 1, 9)
}

然而细心的小伙伴可能就已注意到了:当我们在 Kotlin 中使用 mutableMapOf<K, V>() 在底层创建java.util.LinkedHashMap<K, V>时, Java 类本身并没有定义set方法,我们却仍可以在 Kotlin 侧直接用索引语法。这是因为其借助了 Kotlin 另一个强大的功能:扩展方法

1
2
3
4
5
6
7
8
9
@kotlin.internal.InlineOnly  
public inline operator fun <@kotlin.internal.OnlyInputTypes K, V> Map<out K, V>.get(key: K): V? =
@Suppress("UNCHECKED_CAST") (this as Map<K, V>).get(key)

/**
* Allows to use the index operator for storing values in a mutable map. */@kotlin.internal.InlineOnly
public inline operator fun <K, V> MutableMap<K, V>.set(key: K, value: V): Unit {
put(key, value)
}

另外,在 Kotlin/Java 互操作里,只要 Java 类上确实存在符合签名的 get(...) / set(...) 实例方法,Kotlin 侧通常就可以直接用 []——不需要在 Java 里写什么 operator(Java 也没这个关键字)。Kotlin 编译器会把 a[i]/a[i]=v 按“运算符约定”解析到这些方法上。

关键点

  • 索引参数不必一定是 Int:Map 的 key 就是泛型 K
  • a[x, y] 这种“多参数索引”是合法的,本质是 get(x, y)
  • 这是编译期语法糖:是否能用 [],由静态类型上有没有匹配的 operator get/set 决定。

2) Kotlin 标准集合里 [] 的语义:Map ≠ List/Array

虽然都用 [],但 Map 与 List/Array 的失败策略完全不同。这是 Kotlin 设计中很重要的语义区分,写码时也需要注意。

2.1 Map:查询语义(缺失返回 null)

Map<K, V>

  • map[key] 会调用 get(key)
  • key 不存在 → 返回 null
  • 因此返回类型通常是 V?
1
2
3
val m = mapOf("a" to 1)
println(m["a"]) // 1
println(m["b"]) // null

注意:即使 key 存在,value 也可能是 null(当 V 允许为 null 时)。所以 null 可能表示两种情况:

  1. key 不存在
  2. key 存在但 value 本来就是 null

如果希望“缺 key 就直接失败(抛异常等)”,常见的处理方式是:

  • map.getValue(key):缺失抛 NoSuchElementException
  • requireNotNull(map[key])
  • map[key] ?: error("...")

2.2 List / Array / String:索引语义(越界抛异常)

ListArrayString

  • list[i] / arr[i] / s[i] 是严格索引访问
  • 越界会抛异常(如 IndexOutOfBoundsExceptionStringIndexOutOfBoundsException
1
2
3
val list = listOf(10, 20)
println(list[1]) // 20
println(list[2]) // 抛异常

Kotlin也提供了更安全的替代版本:

  • getOrNull(i):越界返回 null
  • getOrElse(i) { default }:越界返回默认值
1
2
println(list.getOrNull(2))         // null
println(list.getOrElse(2) { -1 }) // -1

2.3 Set:通常没有 []

Set 不支持“按位置”或“按 key”索引,因此没有 set[i] 这种形式。常用的是:

  • x in set / set.contains(x) 判断元素是否存在

3) 和 C / Java / Python 的 [] 对比

[] 是一个长得很像、但在不同语言里“底层机制与语义”差别极大的符号。如果你像我一样日常需要经常在不同语言的项目间来回切换时,可能会搞混,或者直接认为 Kotlin的 [] 与其他语言完全是一种东西。然而他们虽为同一个符号,实际却有着不同的契约。

3.1 Kotlin vs Java:Java 没有通用的 [] 重载

Java 的 [] 只用于数组访问(和数组类型语法),例如 arr[i]
Java 不能obj[key] 解释成方法调用(没有运算符重载),所以 Java 中 Map 访问只能写:

1
map.get(key)

而 Kotlin 的 map[key] 对 JVM 来说仍然只是一次 get(key) 方法调用,是 Kotlin 编译器提供的语法糖。

3.2 Kotlin vs C:C 的 [] 是指针算术(无边界检查)

C 里 a[i] 等价于 *(a + i)

  • 越界通常是未定义行为(可能崩溃,也可能悄悄读错)
  • 不存在“缺失返回 null”的统一约定

而Kotlin 的 List/Array 越界是受控失败(抛异常),错误更可见、更可定位。

3.3 Kotlin vs Python:都“像方法”,但 Kotlin 是静态的、Python 更动态

Python 的:

  • obj[key]obj.__getitem__(key)
  • obj[key] = vobj.__setitem__(key, v)

这和 Kotlin 的“映射到方法”很相似,但差异非常关键:

  • 动态 vs 静态:Python 在运行时决定调用谁;Kotlin 在编译期由静态类型解析 get/set
  • 切片语法:Python 有内建 a[1:5](slice)。
    Kotlin 没有内建切片 []list[1..5] 默认不表示切片(除非自己定义 get(IntRange))。
  • 负数索引:Python a[-1] 是最后一个元素;Kotlin list[-1] 会越界异常(没有特殊语义)。

3.4 Kotlin vs C++:都可重载,但容器默认行为不同

C++ 也能重载 operator[],但很多容器的 operator[] 语义可能是“缺 key 自动插入默认值”(如 std::map)。
Kotlin 的 Map [] 不会插入,它是纯查询;要写入必须显式调用 map[key] = value


注意

  1. List/Array/String 的 [] 越界就抛异常,不像 C 那样“可能悄悄错”。
  2. Kotlin 没有 Python 那种内建切片list[1..3] 不是默认用法。
  3. Kotlin 的 [] 解析看静态类型:能不能用、用哪个 get,编译期就决定了。
  4. Kotlin Map 的 [] 不会像某些 C++ 容器那样访问即插入

4) 总结

  • Kotlin 的 []编译器把语法改写成 operator get/set 调用
  • Map[] 是“查询”,缺失返回 nullList/Array/String[] 是“索引”,越界抛异常
  • 虽然符号相同,但和 C(指针算术)、Java(无重载)、Python(动态 + 切片/负索引)相比,契约差异很大,跨语言迁移时要特别留意。

附:自用速查

场景 Kotlin 写法 失败行为
Map 查值 map[key] key 不存在 → null
Map 强制必须存在 map.getValue(key) 不存在 → 抛异常
List/Array 索引 list[i] / arr[i] 越界 → 抛异常
安全索引 list.getOrNull(i) 越界 → null
Set 判断存在 x in set 返回 true/false

该篇内容为原博客博文,原上传于2023年1月18日。

Sealed ClassSealed Interface是 kotlin 引入的全新特性。在初学 kotlin 时,我就一直没有掌握其用法,甚至到现在也不能在该用的时候立刻想到。

本篇文章主要总结Sealed Class的基本概念和常见用法。

什么是 Sealed

jetbrains 对 Sealed Class/Interface 的介绍如下:

Sealed classes and interfaces represent restricted class hierarchies that provide more control over inheritance. All direct subclasses of a sealed class are known at compile time. No other subclasses may appear outside a module within which the sealed class is defined. For example, third-party clients can’t extend your sealed class in their code. Thus, each instance of a sealed class has a type from a limited set that is known when this class is compiled.

The same works for sealed interfaces and their implementations: once a module with a sealed interface is compiled, no new implementations can appear.

In some sense, sealed classes are similar to enum classes: the set of values for an enum type is also restricted, but each enum constant exists only as a single instance, whereas a subclass of a sealed class can have multiple instances, each with its own state.

从以上介绍可以大致总结出 Sealed Class/Interface 有如下特性:

  • 表示受限的类层次结构,并可以对继承提供更多的控制

  • 密封类的子类在编译期即被确定为一个有限的集合内,不可扩展

  • 密封类的子类只能位于定义了该密封类的

  • 密封类的子类可以有多个实例

介绍中还提到,sealed class 和 enum class 在某种意义上是类似的,实际上,sealed class 诞生的重要原因之一正是为了克服 enum class 在某些场合下的局限性,也即上述特性的第一点和第四点。我们知道,枚举类有两个特性,在某些场合下是优点,但在另外一些场合下却可能成为缺点:

  • 每个枚举类型只能有一个实例(称为枚举常量)

  • 各枚举常量只能使用相同类型的属性

1
2
3
4
5
enum class Drink(val id: Int) {
Milk(1),
Coffee(2),
Water(3)
}

在如上我们创建的枚举类中,各子类(Milk,Coffee,Water)无论有多少对象,都只能有一个实例(即单例的枚举常量),且其属性均只能为枚举类中定义的 Int 类型。

而密封类则取消了以上限制,允许密封类的子类具有多个实例,且各子类可以定义自己的属性。

1
2
3
4
5
6
sealed class Drink {
// 这里直接在定义块内定义子类
class Milk(val id: Int): Drink()
class Coffee(val id: Double): Drink()
class Water(val id: String): Drink()
}

如上,密封类允许各子类具有不同类型的属性,只需要在子类的主构造函数中声明即可。需要注意的是,密封类在继承上的写法与抽象类区别较大,反而更加类似于一般的抽象类。实际上,密封类正是抽象的,不能直接生成实例,且可以具有抽象成员。因此,密封类可以像上述写法一样定义继承自密封类的子类,也可以用object直接定义子类对象,也可以再用sealed class,即密封类继承密封类,实现更加细粒度的类层次结构。

各子类允许有多个不同状态的实例:

1
2
3
4
val milk1 = Drink.Milk(1)
val milk2 = Drink.Milk(2)
println(milk1.id)
println(milk2.id)

上述特性的第二点意义在于,密封类对运行时的扩展是封闭的。程序在编译时,即可通过继承关系确认密封类的全部子类,由此产生了密封类一个非常实用的用处,在写 when 语句时不需要添加 else 分支,因为全部可能的分支在编译时即可确定,没有其他情况:

1
2
3
4
5
fun test(drink: Drink) = when (drink) {
is Drink.Milk -> println("milk")
is Drink.Coffee -> println("coffee")
is Drink.Water -> println("water")
}

上述特性的第三点意义在于,限制了密封类的作用空间,并且随着 kotlin 版本的更迭越来越宽松:从只能在 sealed class 内,到 1.1 支持同一文件内,到 1.5 支持同一包下。

怎么用 Sealed

kotlin 初学者往往会把sealed class当做enum class用,但enum class具有很明显的适用场景,这么做是不合适的。比如,你仅需要一系列相同类型的单例,且不需要任何额外描述,也不需要特殊的函数,那么用枚举就足够了,比如在我的《设计模式之状态模式》中,各状态有统一的处理方法,用枚举来表示。

在 Android 开发中, sealed class有如下一些使用场景:

  • 列表有不同类型的子项(文字、图片),用密封类表示列表的 item

  • 封装网络请求中成功(含有任意类型的请求数据)和失败(含有失败信息,如异常)返回的数据

  • 使用object达到与枚举类似的效果(虽然在 google 的官方示例都出现了这种用法,但还是不推荐。为什么不直接用枚举呢?)

  • 其他一切不满足枚举的应用场景,但需要与枚举类似效果,可以考虑用密封类

关于 Sealed Interface

以上都在讨论sealed class,而sealed interface作为 kotlin1.5 中登场的新特性,也值得说道说道。

密封接口进一步补足了密封类和枚举类的一些不足之处,如枚举类编译后继承自Enum,由于单继承不能再继承其他类,此时可以用密封接口,对枚举类做进一步划分。当然直接用嵌套密封类也未尝不可。

该篇内容为原博客博文,原上传于2021年11月29日。

前言

kotlin是一款十分灵活而又强大的语言。合理地运用kotlin的一些特性,可以极大地提高代码可读性和质量,提高效率。下面我们就来说一说kotlin中的scope function

域函数

我们主要介绍let, also, with, run, apply这五种常用的域函数。

(1)let

先上let函数的源码:

1
2
3
4
5
6
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}

从源码我们可以看出,首先,let是对调用者的扩展函数。其次,let函数接收一个lambda表达式block作为唯一参数,且将调用者本身作为参数传给了lambda。而在函数的内部,首先使用contract进行约束,向编译器表明这个lambda只会执行一次(EXACTLY_ONCE),接着就是调用lambda并返回其返回值。
进而,我们可以得出如下结论:

  1. 由于let函数将调用者作为lambda的参数,因而在let闭包内,可以用it指代调用者;
  2. let函数是具有返回值的,且其返回值应为lambda表达式中的最后一行/return语句。

随之,我们可以得到let函数的作用:

  1. 在明确某一对象实例在一定范围内需要使用时,可以对其调用let,再用it指代;
  2. (常用)对某一可能为空的对象进行统一判空的处理。

这里对作用二作一些解释。对于一个可能为空的对象object,如果不使用let,每次调用其方法或属性时,都要加上?或者!!,以及类似if not null的判空逻辑。但如果使用let,代码就将简化为如下的形式:

1
2
3
4
object?.let {
it.doSomething()
it.id = 0
}

以上代码表示,只有当object不为空时,才会执行let中的逻辑,且由于判空交给了object后的?符来处理,let内部不再需要任何多余的判空。如此以来,判空就变得优雅了许多。

(2)also

先上also函数的源码:

1
2
3
4
5
6
7
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}

从源码可见,also函数的实现思路与let类似,都是对调用者的扩展,都是将调用者作为参数传给lambda,都使用contract进行调用约束,都会在内部执行lambda。但有如下不同:lambda没有返回值,而从return语句看,also返回的是调用者本身。
进而,我们可以得出如下结论:

  1. also函数的应用场景与let相同;
  2. also函数返回值为调用者本身。

(3)with

先上with函数的源码:

1
2
3
4
5
6
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

虽然源码的整体风格和之前两者类似,但其使用方法却截然不同。首先,with是一个通用的扩展函数,其有两个参数:receiver,和一个lambda。注意这里lambda的参数是T.(),它表明将这个lambda函数作为同为类型T的receiver的一个扩展函数,从而能被receiver所调用。进而,我们可以得出如下结论:

  1. 由于with内部是通过扩展函数使得receiver去调用lambda,因而可以在lambda的闭包内部直接访问receiver的public方法/属性。(即没有it等间接指代值)
  2. with函数是具有返回值的。从源码的return语句可以看出,其返回的是lambda表达式的最后一行/return语句。

随之,我们可以得到with函数的作用:
在需要多次调用某一对象的方法或属性时,可以将该对象传给with,再在with内部处理相应逻辑。这样写可以避免多次重复地写该对象。
举个栗子:当我们想写一个alertDialog时,需要先创建builder,对builder进行一系列配置,再调用builder的create()方法返回一条alertDialog。有了with后,我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private lateinit var alertDialog: AlertDialog

...

val builder = AlertDialog.Builder(activity)
alertDialog = with (builder) {
val alertView = layoutInflater.inflate(R.layout.dialog_confirm, null, false)
setView(alertView)
setNegativeButton("取消") { dialog, which ->
Toast.makeText(activity, "已取消", Toast.LENGTH_SHORT).show()
}
setPositiveButton("确定") { dialog, which ->
run {
bill?.let { it1 -> viewModel.deleteBill(it1) }
activity?.onBackPressed()
}
}
create()
}

是不是简洁了许多?

(4)run

还是先上源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}

public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}

run有点类似let和with的结合体,继承了两者各自的优点,因而使用场景十分广泛:首先,它像let一样是对调用者的扩展,因而可以统一判空处理;其次,它又像with一样,传入lambda的是T.(),表明它可以像with一样在闭包内直接访问方法/属性。最后,它返回的是lambda的返回值。

(5)apply

上源码。。。

1
2
3
4
5
6
7
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}

不难看出,apply和run类似于also和let的关系,使用场景类似,只是返回值有差异。
而由于apply返回的是调用者本身,因而它十分适合用来做变量的初始化。

总结

函数名 特点 作用
let 1.在指定域内定义变量it 2.返回最后一行 统一判空处理
also 1.在指定域内定义变量it 2.返回传入对象本身 统一判空处理
with 1.直接访问方法和属性 2.返回最后一行 多次调用时省去变量名
run 1.let和with的结合 2.返回最后一行 统一判空的同时省去it,省去变量名的同时判空
apply 1.同run 2.返回传入对象本身 同run,此外适用于初始化变量

作者纯kotlin小白,如有疏漏与错误,敬请大佬指正!