Android 面试题(1):使用 Handler 时如何有效地避免内存泄漏问题?

这一系列文章致力于为 Android 开发者查漏补缺,面试准备。

所有文章首发于公众号「JaqenAndroid」,长期持续更新。

由于笔者水平有限,总结的答案难免会出现错误,欢迎留言指出,大家一起学习、交流、进步。

什么是内存泄漏?

Java 中采用可达性分析算法判断一个对象是否可被回收。

基本思路是这样的:

通过一系列称为 “GC Roots” 的对象作为起始点,从这个节点向下搜索,搜索走过的路径就是引用链,当一个对象到 GC Roots 没有任何引用链相连,也就是从 GC Roots 到这个对象不可达,则这个对象不可达,可以被回收。

可作为 GC Roots 的对象有:

  • 虚拟机栈中的引用的对象

  • 方法区的静态变量和常量引用的对象

  • 本地方法栈中 JNI 引用的对象

当一个对象不需要在再使用了,本该被回收时, 而另外一个正在使用的对象持有它的引用从而导致它不能被回收,这就导致本该被回收的对象不能被回收而停留在堆内存中,内存泄漏就产生了。

Handler 是如何造成内存泄漏的?

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

private Handler mHandler = new Handler(){
@Override
public void handleMessage(@NonNull Message msg) {
// 处理数据
}
};

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

private void loadData() {
// 耗时任务
// ...
// 发送数据
Message message = Message.obtain();
mHandler.sendMessage(message);
}
}

上面是一段简单的 Handler 的使用。 这种方式有可能造成内存泄漏吗?答案是有可能的。我们来分析下造成内存泄漏的原因?

我们知道 Java中非静态内部类会隐式持有外部类的引用,所以这里创建的 Handler 隐式持有外部类 MainActivity 的引用。

而 Handler 一般用来处理后台线程任务的执行结果,如果在线程任务之慈宁宫过程中,用户关闭了 Activity,此时线程尚未执行完,而该线程持有 Handler 的引用,Handler 又持有 Activity 的引用,就导致了 Activity 无法被回收(即内存泄漏)。

还有一种情况,如果你调用 Handler 的 postDelay() 方法执行了延时任务, 该方法会将你的Handler 装入一个 Message,并把这条 Message 推到 MessageQueue 中,那么在你设定的 delay 到达之前,会有一条 MessageQueue -> Message -> Handler -> Activity 的链,导致你的 Activity 被持有引用而无法被回收。

如果解决 Handler 导致的内存泄漏问题?

方法 1、静态内部类 + 弱引用

既然非静态内部类持有外部类的引用,那么可以将 Handler 声明为静态内部类,Handler 也就不再持有 Activity 的引用,所以 Activity 可以随便被回收。代码如下:

1
2
3
4
5
6
static class MyHandler extends Handler {
@Override
public void handleMessage(Message msg) {
// ...
}
}

此时 Handler 不再持有 Activity 的引用,导致 Handler 无法操作 Activity 中对象,所以可以在 Handler 中添加一个对 Activity 的弱引用( WeakReference ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class MyHandler extends Handler {
WeakReference<Activity > mActivityReference;

MyHandler(Activity activity) {
mActivityReference= new WeakReference<Activity>(activity);
}

@Override
public void handleMessage(Message msg) {
final Activity activity = mActivityReference.get();
if (activity != null) {
//...
}
}
}

弱引用的特点是: 在垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 所以用户在关闭 Activity 之后,就算后台线程还没结束,但由于仅有一条来自 Handler 的弱引用指向 Activity,Activity 也会被回收掉。这样,内存泄露的问题就不会出现了。

方法2: 通过程序逻辑来进行保护

在 Activity 被销毁时及时清除消息,从而及时回收 Activity,避免内存泄漏问题。

1
2
3
4
5
6
7
@Override
protected void onDestroy() {
super.onDestroy();
if (mHandler != null) {
mHandler.removeCallbacksAndMessages(null);
}
}

ijkplayer 编译实践

记录 ijkplayer 的编译过程,以及遇到的问题,有需要的朋友可以参考。

编译环境

Linux 环境

由于主机是 Windows 系统,所以使用 VMware 安装了 Ubuntu 18.0.4 系统。

VMware 安装 Ubuntu 系统的安装步骤网上非常多,这篇文章比较详细,没有经验的可以参考。

https://zhuanlan.zhihu.com/p/38797088

Android SDK

下载地址:https://developer.android.com/studio#downloads

Android NDK

下载地址:https://developer.android.google.cn/ndk/downloads/older_releases.html

注意 NDK 的最小版本支持是 10e,目前不支持 NDK 15!我这边下载的是 android-ndk-r14b

Android SDK 和 Android NDK 下载解压后,需要配置环境变量,可以参考我写的这篇文章。

http://wuzhangyang.com/2019/10/14/ubuntu-android-studio/

安装 git 和 yasm

1
2
sudo apt install git
sudo apt install yasm

注意,如果安装报错,先要执行 sudo apt update 进行更新。

开始编译

拉取 ijkplayer 源码

1
2
3
git clone https://github.com/Bilibili/ijkplayer.git ijkplayer-android
cd ijkplayer-android
git checkout -B latest k0.8.8

初始化 android

1
./init-android.sh

初始化 openssl 支持 https

1
./init-android-openssl.sh

配置编解码器格式支持

默认为最少支持,如果足够你使用,可以跳过这一步,否则可以改为以下配置:

  • module-default.sh 更多的编解码器/格式

  • module-lite-hevc.sh 较少的编解码器/格式(包括 hevc)

  • module-lite.sh 较少的编解码器/格式(默认情况)

1
2
3
4
5
6
7
8
# 进入 config 目录
cd config

# 删除当前的 module.sh 文件
rm module.sh

# 创建软链接 module.sh 指向 module-default.sh
ln -s module-default.sh module.sh

编译 openssl

1
2
3
4
5
6
7
8
# 进入 android/contrib 目录
cd android/contrib

# 清除 openssl 的编译文件
./compile-openssl.sh clean

# 编译 openssl
./compile-openssl.sh all

./compile-openssl.sh 后跟 all 表示编译所有 CPU 架构的 so 库, 如果只编译指定 CPU 架构的 so 库,后面就跟 CPU 架构,比如:./compile-ffmpeg.sh armv7a

这里,在执行 ./compile-openssl.sh all 时出现了编译错误:ERROR: Failed to create toolchain.

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
jaqen@jaqen-virtual-machine:~/Android/Projects/ijkplayer-android/android/contrib$ ./compile-openssl.sh all
====================
[*] check archs
====================
FF_ALL_ARCHS = armv5 armv7a arm64 x86 x86_64
FF_ACT_ARCHS = armv5 armv7a arm64 x86 x86_64

--------------------
[*] make NDK standalone toolchain
--------------------
build on Linux x86_64
ANDROID_NDK=/home/jaqen/Android/Sdk/android-ndk-r14b
IJK_NDK_REL=14.1.3816874
NDKr14.1.3816874 detected

--------------------
[*] make NDK standalone toolchain
--------------------
build on Linux x86_64
ANDROID_NDK=/home/jaqen/Android/Sdk/android-ndk-r14b
IJK_NDK_REL=14.1.3816874
NDKr14.1.3816874 detected
HOST_OS=linux
HOST_EXE=
HOST_ARCH=x86_64
HOST_TAG=linux-x86_64
HOST_NUM_CPUS=4
BUILD_NUM_CPUS=8
Auto-config: --arch=arm
ERROR: Failed to create toolchain.

解决办法是安装 python 后再执行编译。

1
sudo apt install python

编译 ffmpeg

1
2
3
4
5
# 清除 ffmpeg 的编译文件
./compile-ffmpeg.sh clean

# 编译 ffmpeg
./compile-ffmpeg.sh all

执行 ./compile-ffmpeg.sh all 时出现编译错误:linux/perf_event.h: No such file or directory

解决办法是在 config 文件夹下的 module.sh 文件中加入下面两句:

1
2
export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-linux-perf"
export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-bzlib"

再重新执行编译。

编译 ijkplayer

1
2
3
4
5
# 进入 android 目录
cd ..

# 编译 ijkplayer
./compile-ijk.sh all

编译完成之后,在 android/ijkpleyer 文件夹的对应架构文件下,在/src/main/libs/架构名/下生成libijkplayer.solibijkffmpeg.solibijksdl.so 三个文件。

ijkplayer_so

至此,ijkplayer 的编译工作就全部完成了。

编译过程中遇到问题的朋友欢迎留言交流。

不想编译的朋友,可以在公众号 「贾小昆」后台回复 ijk 获取 so 包。

Ubuntu 下 Android Studio 安装与配置

本文主要记录一下 Ubuntu 环境下 Android Studio 的安装和配置,方便查找使用。

Android Studio 安装

Android Studio 下载地址:

https://developer.android.com/studio

下载后,直接解压即可。

然后进入 /android-studio/bin/ 文件下,会看到一个studio.sh的执行文件。

ubuntu_android_studio1

终端命令进入 /android-studio/bin/ , 执行命令 ./studio.sh ,进行安装即可,没啥好说的。

1
2
cd android-studio/bin
./studio.sh

Android Studio 配置

配置 Android 环境变量

终端命令:

1
sudo gedit ~/.bashrc

在文档末尾添加:

1
2
3
4
5
6
7
8
9
10
11
# 配置 Android 环境变量
# 你的ADB路径
ADB=/home/jaqen/Android/Sdk/platform-tools
export ADB
# 你的ANDROID_NDK和ANDROID_SDK 路径
ANDROID_NDK=/home/jaqen/Android/Sdk/android-ndk-r14b
export ANDROID_NDK
ANDROID_SDK=/home/jaqen/Android/Sdk
export ANDROID_SDK
# 加入到PATH路径
PATH=${PATH}:${ADB}:${ANDROID_NDK}:${ANDROID_SDK}

NDK 需要单独去下载:

https://developer.android.google.cn/ndk/downloads/older_releases.html

注意需要执行下面的命令,使修改生效。

1
source ~/.bashrc

设置 Android Studio 别名

1
2
# 你的android studio安装路径
alias as=/home/jaqen/Downloads/android-studio-ide-191.5900203-linux/android-studio/bin/studio.sh

然后,就可以用命令行执行命令 as 启动 Android Studio 了。

制作启动图标

制作一个启动图标,方便鼠标点击启动。

终端命令进入 /android-studio/bin/ ,新建一个 Studio.desktop 文件,然后打开。

1
gedit Studio.desktop

输入以下内容:

1
2
3
4
5
6
7
[Desktop Entry]
Name=AndroidStudio
Type=Application
# 你的Android Studio 图标路径
Icon=/home/jaqen/Downloads/android-studio-ide-191.5900203-linux/android-studio/bin/studio.png
# 你的Android Studio 启动路径
Exec=sh /home/jaqen/Downloads/android-studio-ide-191.5900203-linux/android-studio/bin/studio.sh

保存。

右键该文件 > 属性 > 权限 > 选择允许作为程序执行文件,这个时候文件名和图标都会相应改变了。

好了,双击可以启动 Android Studio 了。

将启动图标添加到 /usr/share/applications/下。

1
sudo mv /home/jaqen/Downloads/android-studio-ide-191.5900203-linux/android-studio/bin/Studio.desktop /usr/share/applications

终端命令:

1
nautilus  usr/share/applications

ubuntu_android_studio2

读《韭菜的自我修养》

近来读到李笑来老师的一本书,光书名就很有意思,叫做《韭菜的自我修养》。

李笑来不亏是曾经当过名师的人,整本书的内容浅显易懂,可读性很高。非常适合大多数新入门的投资者好好读读。

我挑重点概要总结了一些精要,和你们分享下。

什么是韭菜?

书中一开始的定义是:“在交易市场中没赚到钱甚至赔钱的势单力薄的散户”,他们一般是缺乏基本的阅读能力,比如购买所有的产品都不会去看说明书的人,他们拒绝学习,拿来主义,伸手党,他们一进场不管三七二十一就买买买。

一买就跌,一卖就涨?

交易市场有个诡异的定律就是:

你一买,它就开始跌;你一卖,它就开始涨。

为什么?

因为每一次行情结束的根本原因是”入场资金枯竭“。换言之,当连街边卖茶叶蛋的大妈都在讨论股票的时候,那么股市的“入场资金”已经到了枯竭边缘……你想啊,连你这个八竿子打不着的人都知道了,要冲进来赚钱的时候,那交易市场的行情是不是到头了?

对于新手,两句重要的话。

连你都开始进场的时候,牛市就要结束了;

你就应该干看着,啥都不买……到了熊市,等到大家都骂娘的时候,再开始买买买!

亡羊补牢犹未晚矣

很多新手,一进场就犯了个大错,不知道牛市即将结束,激动的“买买买”,自然就被套住了。

更可怕的错误是,一进场就把自己的钱花光了!

还有更可怕的就是,一进场就连借来的钱都花光了!

对于聪明人来说,他们会怎么做?

还有钱的话,在漫长的熊市里,慢慢补仓,降低成本建仓。钱不够的话,就在场外拼命赚钱。

投机者和投资者的区别

区分投机者和投资者的依据在于:

投机者拒绝学习,投资者善于学习。

投资者交易之前,认真研究,深入学习;交易过后,无论输赢,都要总结归纳,修正自己的观念和思考,以便完善下一次的决策——这么做的人,在我眼里都是投资者,哪怕他们是“快进快出”。

而投机者呢?他们拒绝学习,最好有人告诉他怎么买就行,伸手党。这其实就是韭菜,是傻逼。

思考带来决策,决策带来行动,行动改变命运——这是大实话。

韭菜错误的观点

韭菜都认同一个实际上错误的观点:

所谓的交易,是一种“零和游戏”。

也就是说,他们相信自己赚到的钱,是别人赔掉的钱;或者反过来说,他们自己赔掉了多少钱,一定被别人赚走了同样数额的钱。

这是一种错误的观点:

在牛市里,绝大多数人都赚到钱了,少数人赔掉的金额,全然抵不上那么多人赚到的总数;到底哪一根韭菜被割了?在熊市里,绝大多数人都赔钱了,大量的人赔掉的总金额,是少数人赚到的总额的无数倍,谁在割韭菜?

所以,这根本就不是“零和游戏”!

韭菜缺的是实力

所谓“被割离场的韭菜”,本质的原因根本不是他们缺乏耐心。正如你之前看到的那样,哪怕是笨蛋,也天然在不断学习。只要是个人,在条件恰当的情况下,都有足够的耐心——这是事实。

缺乏耐心,其实是表象,本质是什么?本质是缺乏实力。

所以,想要摆脱“韭菜的宿命”,只有一个办法:提高自己的实力。

实力指的是什么?

在交易市场里,实力指的究竟是什么?有一个清楚的定义:

长期稳定的低成本现金流。

控制仓位。

止损线如何制定才合理?

你可以估算一下交易标的的“日常波动幅度”。如果,X的日常波动幅度是 25%,那么,你的止损线,或者换个说法,你的“最大可忍受亏损”应该比 25% 更高,比如 40%,因为你在考虑的是风险,尤其是价格波动剧烈的交易市场里的风险,所以,“做更坏的打算”永远比“盲目乐观”更靠谱。

止损线到底应该定在哪里,有很多因素在起作用。甚至,连交易者的性格都是很重要的因素之一。最要命的是,你的性格确实在此时此刻决定你的行为,但回头仔细观察,你此时此刻的性格,更可能是你过去长期行为所决定的。

降低交易频次

交易频次越高,交易越是接近“零和游戏”。

韭菜想要翻身,说一千道一万,只有一条路可走:

降低交易频次……降低降低再降低。

在交易市场里:

越是短期的预测,越接近于抛硬币;

越是长期的预测,越容易接近真实的逻辑推断……

所以,降低交易频次的本质,是拒绝抛硬币,坚持逻辑推断。

提升收益风险比的方法

新手想要躲避韭菜宿命,就得天天反思,时时刻刻反思,反思之后还要再反思……

回报风险比=可能的回报÷可能的风险

看着公式,就知道,提高回报风险比的方法,无非有两个:要么加大分子,要么减小分母……

减小分母,可行的手段有这么几个:

调整止损线,降低自己的风险承担

降低每次的交易金额在总资金的占比

提高自己在场外的赚钱能力(或者募资能力)

孤独的交易

参考少数人的意见

自己做决定

把握周期

几乎所有的人,冲进交易市场的时候,都自然而然地犯下一个错误:一进场就买买买。

其深层次的原因是,这些人在冲进交易市场的时候,脑子里没有“周期”这个概念。如果交易者脑子里有这个概念,了解这个概念,擅长应用这个概念,那他就不大可能把交易当作零和游戏了,不是吗?

关注周期,以及多个周期背后显现出来的真正趋势,会给你一个全新且更为可靠的世界和视界。

如何把握周期呢?

仔细观察体会绝大多数交易者的情绪。牛市里,FOMO情绪达到顶峰,各种投资者开始ALL-IN的时候,上升趋势渐渐到头了;熊市里,大多数“韭菜”经过失望谩骂而后竟然平静了的时候,下跌趋势渐渐到底了……

Android 应用启动速度优化

应用启动时间的长短,影响到用户体验。对研发人员来说,启动速度是我们的“门面”。

本文主要记录 Android 应用启动速度优化学习笔记。

应用启动类型

应用的启动流程即从点击图标到用户可操作的全部过程。启动分为三种类型:

  • 冷启动:当启动应用时,后台没有该应用的进程,这时系统会首先会创建一个新的进程分配给该应用。

  • 热启动:当启动应用时,后台已有该应用的进程,比如按下 home 键。

  • 温启动:当启动应用时,后台已有该应用的进程,但是启动的入口 Activity 被干掉了,比如按了 back 键,应用虽然退出了,但是该应用的进程是依然会保留在后台。

其中启动最慢的就是冷启动,系统和应用本身的工作都是从零开始。

冷启动开始时,系统有三个任务:

  • 启动 App

  • App 启动后显示一个空白的 Window

  • 创建 App 的进程

在此之后,应用进程马上会执行以下任务:

  • 创建 App 对象

  • 启动 main thread

  • 创建要启动的 Activity

  • 加载 View

  • 布置页面

  • 进行第一次绘制

测量时间方式

adb 命令

使用 adb shell 获取应用启动时间:

1
adb shell am start -W [packageName]/[packageName.AppstartActivity]

输出的结果类似于:

1
2
3
4
5
6
7
8
$ adb shell am start -W com.speed.test/com.speed.test.HomeActivity
Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.speed.test/.HomeActivity }
Status: ok
Activity: com.speed.test/.HomeActivity
ThisTime: 496
TotalTime: 496
WaitTime: 503
Complete
  • ThisTime: 代表一连串启动 Activity 的最后一个 Activity 的启动耗时。
  • TotalTime 表示新应用启动的耗时,包括新进程的启动和 Activity 的启动,但不包括前一个应用 Activity pause 的耗时。
  • WaitTime 返回从 startActivity 到应用第一帧完全显示这段时间。 就是总的耗时,包括前一个应用 Activity pause 的时间和新应用启动的时间。

一般只需关注 TotalTime,即应用自身真正的启动耗时。

缺点:adb 命令无法精确查看方法具体耗费的时间,局限性比较大。

AOP

AOP : Aspect Oriented Programming的缩写,意为:面向切面编程

优点:

  1. 针对同一问题的统一处理
  2. 无侵入添加代码

Android 中使用 AspecJ 实现 AOP。详细内容看这篇文章:Android AspectJ 学习笔记

项目根目录的 build.gradle 添加

1
2
3
4
5
6
7
buildscript {
...
dependencies {
...
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.0'
}
}

app 项目的 build.gradle 新建的 module的 build.gradle 里添加

1
2
3
4
5
6
apply plugin: 'android-aspectjx'

dependencies {
...
api 'org.aspectj:aspectjrt:1.9.5'
}

MyApplication 代码

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
public class MyApplication extends Application {

@Override
public void onCreate() {
super.onCreate();

initBugly();
initBaiduMap();
initJPushInterface();
initShareSDK();

}

private void initBugly() {
try {
Thread.sleep(1000); // 模拟耗费的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}

private void initBaiduMap() {
try {
Thread.sleep(2000); // 模拟耗费的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}

private void initJPushInterface() {
try {
Thread.sleep(3000); // 模拟耗费的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}

private void initShareSDK() {
try {
Thread.sleep(500); // 模拟耗费的时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

创建切面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
public class PerformanceAspect {
private static final String TAG = "PerformanceAspect";

@Around("call(* com.wuzy.aspectjdemo.MyApplication.**(..))")
public void getTime(ProceedingJoinPoint joinPoint) {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
try {
joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
Log.e(TAG, methodName + "方法耗时:" + (System.currentTimeMillis() - startTim
e));
}
}

无需修改任何工程代码,就可以获取运行时长了

performaceaspect

TraceView

TraceView 是 Android SDK 内置的一个工具,它可以加载 trace 文件,用图形的形式展示代码的执行时间、次数及调用栈。

使用方式

1
2
3
Debug.startMethodTracing("文件名");

Debug.stopMethodTracing();

代码运行后,会在

1
mnt/sdcard/Android/data/包名/files

生成一个.trace后缀的文件,然后可以用 Android Studio 的 Profiler 添加打开它。

traceview1

Wall Clock Time:表示实际经过的时间。包含线程在阻塞和等待的时间。

Thread Time:表示实际经过的时间减去线程没有占用 CPU 资源的那部分时间。

Flame Chart:火焰图。y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。 x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。

火焰图就是看顶层的哪个函数占据的宽度最大。只要有”平顶”(plateaus),就表示该函数可能存在性能问题。

Top Down:显示一个函数调用列表,在该列表中展开函数节点会显示函数的被调用方。

Bottom Up:显示一个函数调用列表,在该列表中展开函数节点将显示函数的调用方。

top_down_and_bottom_down

缺点:traceView 的原理就是抓取所有线程的所有函数里的信息,所以会导致程序变慢,工具本身带来的性能开销过大,有时无法反映真实的情况。比如一个函数本身的耗时是 1 秒,开启 TraceView 后可能会变成 5 秒。

Systrace + 函数插桩

Systrace 原理:在系统的一些关键链路(如SystemServcie、虚拟机、Binder驱动)插入一些信息(Label),
通过 Label 的开始和结束来确定某个核心过程的执行时间,然后把这些Label信息收集起来得到系统关键路径的运行时间信息,最后得到整个系统的运行性能信息。Android Framework 里面一些重要的模块都插入了 label 信息(Java 层通过 android.os.Trace 类完成,native层通过 ATrace 宏完成),用户 App 中可以添加自定义的 Lable,这样就组成了一个完成的性能分析系统。

具体使用教程可以看这篇文章:手把手教你使用Systrace(一)

Systrace 的优点:

  • 可以看大整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用,渲染耗时、线程锁、GC 耗时等。
  • 性能损耗可以接受。

异步优化

异步优化的核心思想:子线程来分担主线程的任务,并减少运行时间。

FixThreadPool

线程池核心个数

1
2
3
4
5
// 获得当前CPU的核心数
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();

// 设置线程池的核心线程数2-4之间,但是取决于CPU核数
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));

创建线程池

1
2
// 创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(CORE_POOL_SIZE);

将耗时方法放在线程池中,不影响主线程页面加载。对于必须要先执行完毕才能进入页面的情况,使用 CountDownLatch 处理。

MyApplication#onCreate:

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
 @Override
public void onCreate() {
super.onCreate();
// Debug.startMethodTracing();

final CountDownLatch latch = new CountDownLatch(1);
ExecutorService executorService = Executors.newFixedThreadPool(CORE_POOL_SIZE);

executorService.submit(new Runnable() {
@Override
public void run() {
initBugly();
latch.countDown();
}
});
executorService.submit(new Runnable() {
@Override
public void run() {
initBaiduMap();
}
});
executorService.submit(new Runnable() {
@Override
public void run() {
initJPushInterface();
}
});
executorService.submit(new Runnable() {
@Override
public void run() {
initShareSDK();
}
});

try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}

// Debug.stopMethodTracing();
}

上面代码中:只有在 initBugly 执行完毕后才能跳转页面,而 initBugly 是可以在子线程执行,所以可以采用线程池 + CountDownLatch 实现。

但是,对于多个耗时任务存在依赖关联,比如必须先执行完A,根据A的返回值,再执行B,然后根据B的返回再执行C,任务串联的情况,使用线程池 + CountDownLatch 就比较麻烦。

启动器

先将任务分类:

![task_classification ](android-launch-time-performance-optimization/task_classification .jpg)

  • head task : 我们会把一些必须先启动的task放到这里

  • 主线程:将必须要在主线程中初始化的task放入这里

  • 并发:将非必须在主线程中初始化的task放入这里

  • tail task: 一些在所有任务执行完毕之后才去初始化的放入这里,比如一些log打印等

  • ilde task: 通过字面就知道了将一些可以有空再初始化的task放入这里

启动器的目的就是保证并发任务的执行顺序的正确性。任务执行顺序的排序采用:有向无环图的拓扑排序

拓扑排序(Topological Sorting)是一个有向无环图(DAG, Directed Acyclic Graph)的所有顶点的线性序列。

且该序列必须满足下面两个条件:

  • 每个顶点出现且只出现一次。

  • 若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面。

拓扑排序的写法思路:

1、从 DAG 图中选择一个 没有前驱(即入度为0)的顶点并输出。

2、从图中删除该顶点和所有以它为起点的有向边。

3、重复 1 和 2 直到当前的 DAG 图为空或当前图中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。

DAG

于是,得到拓扑排序后的结果是 { 1, 2, 4, 3, 5 }。

启动器外部调用:

1
2
3
4
5
6
TaskDispatcher instance = TaskDispatcher.createInstance();
instance.addTask(new InitBuglyTask()) // 默认添加,并发处理
.addTask(new InitBaiduMapTask()) // 在这里需要先处理了另外一个耗时任务initShareSDK,才能再处理它
.addTask(new InitJPushTask()) // 等待主线程处理完毕,再进行执行
.start();
instance.await();

启动器主要流程:

starter_flow

具体代码:PerformanceDemo

启动窗口优化

使用 Activity 的 windowBackground 属性为启动的 Activity 提供一个闪屏预览界面(layer-list),这样点击应用图标会立马显示闪屏界面。具体操作方法:

Layout XML:

1
2
3
4
5
6
7
8
9
10
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
<!-- 背景颜色 -->
<item android:drawable="@android:color/white"/>
<!-- 闪屏页图片 -->
<item>
<bitmap
android:src="@drawable/product_logo_144dp"
android:gravity="center"/>
</item>
</layer-list>

style:

1
2
3
4
<style name="AppTheme.Launcher">
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@mipmap/layer-list</item>
</style>

Manifest:

1
2
<activity ...
android:theme="@style/AppTheme.Launcher" />

Activity:

1
2
3
4
5
6
7
8
9
public class MyMainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
// 替换回原来的主题,注意在 super.onCreate 之前调用
setTheme(R.style.Theme_MyApp);
super.onCreate(savedInstanceState);
// ...
}
}

这种方案在通过交互体验优化了展示效果,但并没有真正的加速启动。

对于中低端机,总的闪屏时间会更长,建议在 Android 6.0 或者 Android 7.0 以上才启用“闪屏优化” 方案。

延迟初始化

首页渲染完成后,再初始化数据,也就是延迟初始化,目的就是让界面先显示出来,保证 UI 绘制的流畅性。

核心方法是在 Activity 的 onCreate 函数中加入下面的方法 :

1
2
3
4
5
6
getWindow().getDecorView().post(new Runnable() {
@Override
public void run() {
myHandler.post(mLoadingRunnable);
}
});

这里的 run 方法是在 Activity 的 onResume 之后执行的。

关于这种方案的机制参见 :Android 应用启动优化:一种 DelayLoad 的实现和原理(上篇)

建议

1、减少布局层级,建议使用约束布局 ConstraintLayout。

2、去掉无用代码、重复逻辑。

3、避免 Application 中创建线程池,尽量延迟操作。

4、避免启动过多工作线程。

5、尽量减少 GC 的次数,避免造成主线程长时间的卡顿。

6、一句话准则:可以异步的都异步,不可以异步的尽量延迟。

参考

https://www.androidperformance.com/2015/11/18/Android-app-lunch-optimize-delay-load/

https://www.jianshu.com/p/f5514b1a826c

https://www.androidperformance.com/2015/11/18/Android-app-lunch-optimize-delay-load/

https://developer.android.google.cn/topic/performance/launch-time.html

https://zhuanlan.zhihu.com/p/27331842

https://juejin.im/user/5d618e106fb9a06b084d0073/posts

Android 性能分析工具 TraceView

在做应用启动、卡顿优化时,经常会用到 Android 性能分析工具 TraceView,这里简单介绍下 TraceView 的基础使用。

TraceView 是什么

TraceView 是 Android SDK 内置的一个工具,它可以加载 trace 文件,用图形的形式展示代码的执行时间、次数及调用栈,便于我们分析。

生成 trace 文件

trace 文件是 log 信息文件的一种,可以通过代码,Android Studio,或者 DDMS 生成。

使用代码生成 trace 文件

在想要记录的地方调用 Debug.startMethodTracing("sample"),参数指定 trace 文件的名称。

在结束记录的地方调用 Debug.stopMethodTracing(),文件会被保存到 /sdcard/Android/data/packageName/files 文件夹下。

1
2
3
Debug.startMethodTracing("sample"); // 开始 trace
...
Debug.stopMethodTracing(); // 结束 trace

可以使用 adb 命令导出 trace 文件,使用 Android Studio Profiler 或者 DDMS 打开。

使用 Android Studio 生成 trace 文件

点击工具栏中的 Profiler(Android Studio 版本是 3.4.2), 点击 CPU 时间轴上的任意位置以打开 CPU Profiler。

cpu_profiler1

1. 事件时间轴:显示应用中的 Activity 在其生命周期内不断转换而经历各种不同状态的过程,并指示用户与设备的交互,包括屏幕旋转事件。

2. CPU 时间轴 : 显示应用的实时 CPU 使用率以及应用当前使用的线程总数。通过沿时间线的水平轴移动鼠标,还可以检查历史 CPU 使用率数据。

3. 线程活动时间轴:应用进程的所有线程。不同颜色对应的含义:

  • 绿色: 表示线程处于活动状态或准备使用 CPU。 即,它正在“运行中”或处于“可运行”状态。

  • 黄色: 表示线程处于活动状态,但它正在等待一个 I/O 操作(如磁盘或网络 I/O),然后才能完成它的工作。

  • 灰色: 表示线程正在休眠且没有消耗任何 CPU 时间。 当线程需要访问尚不可用的资源时偶尔会发生这种情况。 线程进入自主休眠或内核将此线程置于休眠状态,直到所需的资源可用。

要开始记录跟踪数据,点击 CPU Profiler 顶部的下拉框选择适当的记录配置:

  • 对 Java 方法采样:在应用的 Java 代码执行期间,频繁捕获应用的调用堆栈。分析器会比较捕获的数据集,以推导与应用的 Java 代码执行有关的时间和资源使用信息。

  • 跟踪 Java 方法 :在运行时检测应用,以在每个方法调用开始和结束时记录一个时间戳。系统会收集并比较这些时间戳,以生成方法跟踪数据,包括时间信息和 CPU 使用率。

  • 对 C/C++ 函数采样:捕获应用的原生线程的采样跟踪数据。

cpu_profiler2

选择配置后,点击 Record 进行跟踪,交互完成后点击 Stop 结束数据跟踪。分析器会分析 trace 数据,如下图所示。

cpu_profiler3

1. 选择时间范围: 确定要在跟踪窗格中检查所记录时间范围的哪一部分。 当首次记录函数跟踪时,CPU Profiler 将在 CPU 时间线中自动选择完整长度。 如果想仅检查所记录时间范围一小部分的函数跟踪数据,可以点击并拖动突出显示的区域边缘以修改其长度。

2. 时间戳: 用于表示所记录函数跟踪的开始和结束时间(相对于分析器从设备开始收集 CPU 使用率信息的时间)。 可以点击时间戳以自动选择完整记录。

3. 跟踪窗格: 用于显示所选的时间范围和线程的函数跟踪数据。

4. 跟踪数据窗格标签:通过Call Chart(调用图表)、Flame Chart(火焰图)、 Top Down 树或 Bottom Up 树的形式显示函数跟踪。

  • Call Chart : 水平轴表示函数调用(或调用方)的时间,并沿垂直轴显示其被调用者。 对系统 API 的函数调用显示为橙色,对应用自有函数的调用显示为绿色,对第三方 API(包括 Java 语言 API)的函数调用显示为蓝色。

  • Flame Chart: 一个倒置的调用图表,其中水平轴不再代表时间线,它表示每个函数相对的执行时间。

  • Top Down:显示一个函数调用列表,在该列表中展开函数节点会显示函数的被调用方。

  • Bottom Up:显示一个函数调用列表,在该列表中展开函数节点将显示函数的调用方。

5. 时间参考菜单 :确定如何测量每个函数调用的时间信息:

  • Wall clock time:实际经过的时间。

  • Thread time:实际经过的时间减去线程没有消耗 CPU 资源的时间。

使用 DDMS 生成 trace 文件

DDMS 即 Dalvik Debug Monitor Server ,是 Android 调试监控工具,它为我们提供了截图,查看 log,查看视图层级,查看内存使用等功能。

Android Studio 3.0 后可在 Android SDK 的 tools 目录,找到 monitor.bat,使用命令行启动它,就能打开 DDMS。

DDMS 界面点击 Start Method Profiling 按钮,开始记录 trace,同一个按钮停止 trace。DDMS 会自动启用 TraceView 加载 trace 文件,如下图:

ddms_trace

图中上半部分展示了不同线程的执行时间,其中不同的颜色代表不同的方法,同一颜色越长,说明执行时间越长,空白表示这个时间段内没有执行内容。

下半部分展示了不同方法的执行时间信息。各个指标的含义:

  • Incl Cpu Time:方法占用的 CPU 时间(包括调用子函数所消耗的时间)。

  • Excl Cpu Time:方法自身占用的 CPU 时间(不包括调用其他方法所消耗的时间)。

  • Incl Real Time:方法运行的真实时间(包括调用子函数所消耗的时间)。

  • Excl Real Time:方法自身运行的真实时间(不包括调用其他方法所消耗的时间)。

  • Calls+RecurCalls/Total:方法被调用的次数+重复调用的次数。

  • Cpu Time/Call:方法调用 CPU 时间与调用次数的比,相当于方法平均执行时间。

  • Real Time/Call:同 Cpu Time/Call 类似,只不过统计单位换成了真实时间。

在分析耗时的时候一般有两种情况:

  • 调用次数不多。但是,本身就非常耗时。

  • 本身不是很耗时。但是,调用非常频繁。

第一种情况,可以使用 Cpu Time 来查看它的耗时情况。

第二种情况,可以使用 Calls+RecurCalls/Total 来查看它的调用情况。

参考

https://developer.android.google.cn/studio/profile/cpu-profiler

https://blog.csdn.net/u011240877/article/details/54347396

程序员成长离不开的技能

很多人,在学生阶段习惯了被动、填鸭式学习,进入社会后,不知道如何去自我学习,严重限制了提升自己的知识和技能的机会。

在这个飞速发展的世界里,做一个终身学习者,自我学习的能力越强越好。

最近看了一本书《软技能: 代码之外的生存指南》,里面的一个主题,讲述了技术人员如何在新技术发展日新月异的世界里,如何快速高效学习技术,以下是我整理的「十步学习法」笔记,供大家参考。

十步学习法的基本思想就是:

要对自己要学的内容有个基本的了解:了解自己不知道什么就足矣。然后,利用这些信息勾勒出学习的范围,即需要学哪些内容,以及学成之后又会获得什么。依靠这些知识,你可以找出各种资源来帮助自己学习。最后,你们可以创建自己的学习计划,列出要去学习哪些相关课程,筛选学习材料,只保留能帮助自己达成目标的优质内容。

一旦完成这些工作,你对自己要学什么和怎样学都了然于胸,你就可以把控自己的学习计划中的每个关键点,通过「学习—实践—掌握—教授」(Learning, Doing, Learning and Teaching,LDLT) 的过程,获得对该学科的深刻理解,同时你也向着自己的目标前进。

ten-step-learing-method

第 1 步 了解全局

unknown unknowns,即你根本不知道自己不知道。

在你打开一本新书开始阅读的时候,你对自己所不知道的一无所知。闷头往下看的话,会出现学费所需、力所不及的情况。

所以,在学习某一技术之前,至少要对其有所了解,对技术有一个全局的了解,这一点非常重要。

第 2 步 确定范围

充分利用第 1 步获得的信息,根据自己的需求,明确自己到底要学什么,确定学习范围。

这一步容易犯的一个错误:试图解决太大的问题而让自己陷入困境中。

可以将大的主题,分解小而聚焦的主题。

因此,学习范围务必大小适当,符合自己的学习理由,同时要保持专注。

第 3 步 定义目标

确定自己的学习目标,明确学习完成后应该达成的效果,根据简明清晰的目标,勾勒出勤奋学习后成功的图景。成功的标准应该是具体的,无二义性的。

第 4 步 寻找资源

尽可能的尝试多种渠道和方式收集与学习主题相关的资源。这个阶段,资源的质量无需考虑。

第 5 步 创建学习计划

打造自己的学习计划,一个好方法就是观察别人是如何教你感兴趣的主题的。通览你收集的全部资源,你就对自己需要哪些内容及如何组合这些内容有更清晰的认识。

第 6 步 筛选资源

对找到的资源进行筛选,挑选出最有价值的几项来帮助你实现自己的目标。

现在,你可以就你想要了解的一个主题,实际演练一下以上 6 个步骤了。

第 7 步 开始学习 浅谈辄止

大多数人,包括我自己,在学习的过程中通常会犯两类错误:第一类错误是在知之不多的情况下就盲目开始,即行动太快;第二类错误是在行动之前准备过多,即行动太晚。

这一步的关键在于避免过犹不及。开始学习时很容易失去自控力,深入学习计划学习中列出的所有资源。你要专注于掌握自己所需的、能在下一步动手操作的最小量的知识。

第 8 步 动手操作 边玩边学

在掌握操作动手最小量的知识的情况下亲自操作和亲身体验。

在探索和实践过程中,会产生的各种问题。这些问题会引导着你走向真正重要的方向。当回头寻找问题的答案时,不只是这些问题迎刃而解,而且你记得的东西比你学习的东西要多得多,因为你所学到的都是对你很重要的东西。

第 9 步 全面掌握 学以致用

好奇心是学习特别是自学的重要组成部分。

为了有效利用自己选择的资料,为了上一步生产的问题寻求答案(带着问题学习)。不用担心回头再去操作,付出更多,因为这不仅能够让你找到问题的答案,也能让你学习新东西。给自己足够多的时间去深入理解自己的主题,你可以阅读,可以实验,可以观察,也可以操作。试着把自己正在学习的内容与最终目标关联起来。

第 10 步 乐为人师 融会贯通

要想深入掌握一门学问,并且融会贯通,那么必须要做到能够教授给别人,在这一过程中,你要切实刨析并理解自己所学的知识,将其内化到自己的思想;同时,也要用能够让他人理解的方式精心组织这些信息。

在这个过程中,你会发现很多自以为明白的知识点,其实并没有你想象的那么透彻。这一过程会将那些以前自己没太明白的东西联系起来,并简化到自己的大脑中已有的信息,将它们浓缩并经常复习。

如何使用 Python 爬取微信公众号文章

我比较喜欢看公众号,有时遇到一个感兴趣的公众号时,都会感觉相逢恨晚,想一口气看完所有历史文章。但是微信的阅读体验挺不好的,看历史文章得一页页的往后翻,下一次再看时还得重复操作,很是麻烦。

于是便想着能不能把某个公众号所有的文章都保存下来,这样就很方便自己阅读历史文章了。

话不多说,下面我就介绍如何使用 Python 爬取微信公众号所有文章的。

主要有以下步骤:

1 使用 Fiddler 抓取公众号接口数据

2 使用 Python 脚本获取公众号所有历史文章数据

3 保存历史文章

Fiddler 抓包

Fiddler 是一款抓包工具,可以监听网络通讯数据,开发测试过程中非常有用,这里不多做介绍。没有使用过的可以查看这篇文章,很容易上手。

https://blog.csdn.net/jingjingshizhu/article/details/80566191

接下来,使用微信桌面客户端,打开某个公众号的历史文章,这里以我的公众号举例,如下图。

wechat_article

如果你的 fiddler 配置好了的话,能够看到如下图的数据。

fiddler_wechat

图中包含抓取的 url、一些重要的参数和我们想要的数据。

这些参数中,offset 控制着翻页,其他参数在每一页中都是固定不变的。

接口返回的数据结构如下图,其中 can_msg_continue 字段控制着能否翻页,1 表示还有下一页,0 表示没有已经是最后一页了。 next_offset 字段就是下一次请求的 offset 参数。

wechat_json

构造请求,获取数据

接下来我们的目标就是根据 url 和一些参数,构建请求,获取标题、文章 url 和日期等数据,保存数据。

保存数据一种是使用 pdfkit 将 文章 url 保存为 pdf 文件;另一种是先保存 html 文件,然后将 html 制作成 chm 文件。

1 将 文章 url 保存为 pdf 文件,关键代码如下:

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
def parse(index, biz, uin, key):
# url前缀
url = "https://mp.weixin.qq.com/mp/profile_ext"

# 请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 "
"Safari/537.36 MicroMessenger/6.5.2.501 NetType/WIFI WindowsWechat QBCore/3.43.901.400 "
"QQBrowser/9.0.2524.400",
}

proxies = {
'https': None,
'http': None,
}

# 重要参数
param = {
'action': 'getmsg',
'__biz': biz,
'f': 'json',
'offset': index * 10,
'count': '10',
'is_ok': '1',
'scene': '124',
'uin': uin,
'key': key,
'wxtoken': '',
'x5': '0',
}

# 发送请求,获取响应
response = requests.get(url, headers=headers, params=param, proxies=proxies)
response_dict = response.json()

print(response_dict)

next_offset = response_dict['next_offset']
can_msg_continue = response_dict['can_msg_continue']

general_msg_list = response_dict['general_msg_list']
data_list = json.loads(general_msg_list)['list']

# print(data_list)

for data in data_list:
try:
# 文章发布时间
datetime = data['comm_msg_info']['datetime']
date = time.strftime('%Y-%m-%d', time.localtime(datetime))

msg_info = data['app_msg_ext_info']

# 文章标题
title = msg_info['title']

# 文章链接
url = msg_info['content_url']

# 自己定义存储路径(绝对路径)
pdfkit.from_url(url, 'C:/Users/admin/Desktop/wechat_to_pdf/' + date + title + '.pdf')

print(title + date + '成功')

except:
print("不是图文消息")

if can_msg_continue == 1:
return True
else:
print('爬取完毕')
return False

2 保存 html 文件,关键代码如下

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
def parse(index, biz, uin, key):

# url前缀
url = "https://mp.weixin.qq.com/mp/profile_ext"

# 请求头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 "
"Safari/537.36 MicroMessenger/6.5.2.501 NetType/WIFI WindowsWechat QBCore/3.43.901.400 "
"QQBrowser/9.0.2524.400",
}

proxies = {
'https': None,
'http': None,
}

# 重要参数
param = {
'action': 'getmsg',
'__biz': biz,
'f': 'json',
'offset': index * 10,
'count': '10',
'is_ok': '1',
'scene': '124',
'uin': uin,
'key': key,
'wxtoken': '',
'x5': '0',
}

# 发送请求,获取响应
reponse = requests.get(url, headers=headers, params=param, proxies=proxies)
reponse_dict = reponse.json()

# print(reponse_dict)
next_offset = reponse_dict['next_offset']
can_msg_continue = reponse_dict['can_msg_continue']

general_msg_list = reponse_dict['general_msg_list']
data_list = json.loads(general_msg_list)['list']

print(data_list)

for data in data_list:
try:
datetime = data['comm_msg_info']['datetime']
date = time.strftime('%Y-%m-%d', time.localtime(datetime))

msg_info = data['app_msg_ext_info']

# 标题
title = msg_info['title']

# 内容的url
url = msg_info['content_url'].replace("\\", "").replace("http", "https")
url = html.unescape(url)
print(url)

res = requests.get(url, headers=headers, proxies=proxies)
with open('C:/Users/admin/Desktop/test/' + title + '.html', 'wb+') as f:
f.write(res.content)

print(title + date + '成功')

except:
print("不是图文消息")

if can_msg_continue == 1:
return True
else:
print('全部获取完毕')
return False

保存文章

保存为 pdf 文件,用到了 python 的第三方库 pdfkit 和 wkhtmltopdf。

安装 pdfkit:

1
pip install pdfkit

安装 wkhtmltopdf:

下载地址:

https://wkhtmltopdf.org/downloads.html

安装后将 wkhtmltopdf 目录下的 bin 添加到环境变量中。

保存为 chm 文件,可以下载 Easy CHM ,使用这个软件可以将 html 制作成 chm,使用教程网上比较多。

下载地址:

http://www.etextwizard.com/cn/easychm.html

效果图:

chm

pdf 和 chm 对比

pdf 支持多终端,阅读体验好,但是有个大坑,就是微信文章保存的 pdf 没有图片,很影响阅读体验,暂未找到解决办法。

chm 的好处是可以建立索引,查看文章方便。一个公众号制作成一个 chm 文件,管理方便。不会出现图片不显示问题。

所以推荐将爬取到的公众号文章保存为 chm 文件,方便阅读。

需要完整代码和文中相关软件的朋友,可以关注公众号「贾小昆」,后台回复关键词 ”公众号文章“ 获取。

YUV 格式详解

一般的视频采集芯片输出的码流一般都是 YUV 格式数据流,后续视频处理也是对 YUV 数据流进行编码和解析。所以,了解 YUV 数据流对做视频领域的人而言,至关重要。

在介绍 YUV 格式之前,首先介绍一下我们熟悉的 RGB 格式。

RGB

RGB 分别表示红(R)、绿(G)、蓝(B),也就是三原色,将它们以不同的比例叠加,可以产生不同的颜色。

比如一张 1920 * 1280 的图片,代表着有 1920 * 1280 个像素点。如果采用 RGB 编码方式,每个像素点都有红、绿、蓝三个原色,其中每个原色占用 8bit,每个像素占用 24bit,也就是 3 个字节。

那么,一张 1920 * 1280 大小的图片,就占用 1920 * 1280 * 3 / 1024 / 1024 = 7.03MB 存储空间。

YUV

YUV 编码采用了明亮度和色度表示每个像素的颜色。

其中 Y 表示明亮度(Luminance、Luma),也就是灰阶值。

U、V 表示色度(Chrominance 或 Chroma),描述的是色调和饱和度。

YCbCr 其实是 YUV 经过缩放和偏移的翻版。其中 Y 与 YUV 中的 Y 含义一致,Cb,Cr 同样都指色彩,只是在表示方法上不同而已。YCbCr 其中 Y 是指亮度分量,Cb 指蓝色色度分量,而 Cr 指红色色度分量。

yuv-image

YUV 优点

对于 YUV 所表示的图像,Y 和 UV 分量是分离的。如果只有 Y 分量而没有 UV 分离,那么图像表示的就是黑白图像。彩色电视机采用的就是 YUV 图像,解决与和黑白电视机的兼容问题,使黑白电视机也能接受彩色电视信号

人眼对色度的敏感程度低于对亮度的敏感程度。主要原因是视网膜杆细胞多于视网膜锥细胞,其中视网膜杆细胞的作用就是识别亮度,视网膜锥细胞的作用就是识别色度。所以,眼睛对于亮度的分辨要比对颜色的分辨精细一些。

利用这个原理,可以把色度信息减少一点,人眼也无法查觉这一点。

所以,并不是每个像素点都需要包含了 Y、U、V 三个分量,根据不同的采样格式,可以每个 Y 分量都对应自己的 UV 分量,也可以几个 Y 分量共用 UV 分量。相比 RGB,能够节约不少存储空间。

YUV 采样格式

YUV 图像的主流采样方式有如下三种:

  • YUV 4:4:4 采样
  • YUV 4:2:2 采样
  • YUV 4:2:0 采样

YUV 4:4:4

YUV 4:4:4 表示 Y、U、V 三分量采样率相同,即每个像素的三分量信息完整,都是 8bit,每个像素占用 3 个字节。

如下图所示:

yuv444

1
2
3
四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]
采样的码流为: Y0 U0 V0 Y1 U1 V1 Y2 U2 V2 Y3 U3 V3
映射出的像素点为:[Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]

可以看到这种采样方式与 RGB 图像大小是一样的。

YUV 4:2:2

YUV 4:2:2 表示 UV 分量的采样率是 Y 分量的一半。

如下图所示:

yuv422

1
2
3
四个像素为: [Y0 U0 V0] [Y1 U1 V1] [Y2 U2 V2] [Y3 U3 V3]
采样的码流为: Y0 U0 Y1 V1 Y2 U2 Y3 U3
映射出的像素点为:[Y0 U0 V1]、[Y1 U0 V1]、[Y2 U2 V3]、[Y3 U2 V3]

其中,每采样一个像素点,都会采样其 Y 分量,而 U、V 分量都会间隔采集一个,映射为像素点时,第一个像素点和第二个像素点共用了 U0、V1 分量,以此类推。从而节省了图像空间。

比如一张 1920 * 1280 大小的图片,采用 YUV 4:2:2 采样时的大小为:

(1920 * 1280 * 8 + 1920 * 1280 * 0.5 * 8 * 2 ) / 8 / 1024 / 1024 = 4.68M

可以看出,比 RGB 节省了三分之一的存储空间。

YUV 4:2:0

YUV 4:2:0 并不意味着不采样 V 分量。它指的是对每条扫描线来说,只有一种色度分量以 2:1 的采样率存储,相邻的扫描行存储不同的色度分量。也就是说,如果第一行是 4:2:0,下一行就是 4:0:2,在下一行就是 4:2:0,以此类推。

如下图所示:

yuv420

1
2
3
4
5
6
7
8
9
10
11
图像像素为:
[Y0 U0 V0]、[Y1 U1 V1]、 [Y2 U2 V2]、 [Y3 U3 V3]
[Y5 U5 V5]、[Y6 U6 V6]、 [Y7 U7 V7] 、[Y8 U8 V8]

采样的码流为:
Y0 U0 Y1 Y2 U2 Y3
Y5 V5 Y6 Y7 V7 Y8

映射出的像素点为:
[Y0 U0 V5]、[Y1 U0 V5]、[Y2 U2 V7]、[Y3 U2 V7]
[Y5 U0 V5]、[Y6 U0 V5]、[Y7 U2 V7]、[Y8 U2 V7]

其中,每采样一个像素点,都会采样 Y 分量,而 U、V 分量都会隔行按照 2:1 进行采样。

一张 1920 * 1280 大小的图片,采用 YUV 4:2:0 采样时的大小为:

(1920 * 1280 * 8 + 1920 * 1280 * 0.25 * 8 * 2 ) / 8 / 1024 / 1024 = 3.51M

相比 RGB,节省了一半的存储空间。

YUV 存储格式

YUV 数据有两种存储格式:平面格式(planar format)和打包格式(packed format)。

  • planar format:先连续存储所有像素点的 Y,紧接着存储所有像素点的 U,随后是所有像素点的 V。
  • packed format:每个像素点的 Y、U、V 是连续交错存储的。

因为不同的采样方式和存储格式,就会产生多种 YUV 存储方式,这里只介绍基于 YUV422 和 YUV420 的存储方式。

YUYV

YUYV 格式属于 YUV422,采用打包格式进行存储,Y 和 UV 分量按照 2:1 比例采样,每个像素都采集 Y 分量,每隔一个像素采集它的 UV 分量。

Y0 U0 Y1 V0 Y2 U2 Y3 V2

Y0 和 Y1 共用 U0 V0 分量,Y2 和 Y3 共用 U2 V2 分量。

UYVY

UYVY 也是 YUV422 采样的存储格式中的一种,只不过与 YUYV 排列顺序相反。

U0 Y0 V0 Y1 U2 Y2 V2 Y3

YUV 422P

YUV422P 属于 YUV422 的一种,它是一种 planer 模式,即 Y、U、V 分别存储。

YUV420P 和 YUV420SP

YUV420P 是基于 planar 平面模式进行存储,先存储所有的 Y 分量,然后存储所有的 U 分量或者 V 分量。

yuv420p

同样,YUV420SP 也是基于 planar 平面模式存储,与 YUV420P 的区别在于它的 U、V 分量是按照 UV 或者 VU 交替顺序进行存储。

yuv420sp

YU12 和 YU21

YU12 和 YV12 格式都属于 YUV 420P 类型,即先存储 Y 分量,再存储 U、V 分量,区别在于:YU12 是先 Y 再 U 后 V,而 YV12 是先 Y 再 V 后 U 。

NV21 和 NV21

NV12 和 NV21 格式都属于 YUV420SP 类型。它也是先存储了 Y 分量,但接下来并不是再存储所有的 U 或者 V 分量,而是把 UV 分量交替连续存储。

NV12 是 IOS 中有的模式,它的存储顺序是先存 Y 分量,再 UV 进行交替存储。

NV21 是 安卓 中有的模式,它的存储顺序是先存 Y 分量,在 VU 交替存储。

YUV 与 RGB 转换

YUV 与 RGB 之间的转换,就是将 图像所有像素点的 R、G、B 分量和 Y、U、 分量相互转换。

有如下转换公式:

yuv2rgb

rgb2yuv

参考

https://zh.wikipedia.org/wiki/YUV

https://glumes.com/post/ffmpeg/understand-yuv-format/

https://blog.csdn.net/MrJonathan/article/details/17718761

http://www.fourcc.org/pixel-format/yuv-i420/

https://msdn.microsoft.com/zh-cn/library/ms867704.aspx

Kotlin Koans 学习笔记

Kotlin Koans 是 Kotlin 官方推出的一系列 Kotlin 语法练习。

一共分为 6 个模块,每个模块若干任务,每个任务都有一系列单元测试,目标就是编码通过单元测试。

本文是在学习 Kotlin Koans 过程中将相关语法点做一个简单的记录。

Introduction

Hello_World

和大多数语言一样,第一个任务的名称就是 Hello World。这个任务很简单,就是要求 task0 函数返回一个字符串ok

1
2
3
fun task0(): String {
return "OK"
}

Kotlin 中函数使用关键字fun声明,返回类型在函数名称的后面,中间以:分开。

Java to Kotlin Convert

将 Java 代码转化为 Kotlin 代码。

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
// java
public String task1(Collection<Integer> collection) {
StringBuilder sb = new StringBuilder();
sb.append("{");
Iterator<Integer> iterator = collection.iterator();
while (iterator.hasNext()) {
Integer element = iterator.next();
sb.append(element);
if (iterator.hasNext()) {
sb.append(", ");
}
}
sb.append("}");
return sb.toString();
}

//kotlin
fun task1(collection: Collection<Int>): String {
val sb = StringBuilder()
sb.append("{")
val iterator = collection.iterator()
while (iterator.hasNext()) {
val element = iterator.next()
sb.append(element)
if (iterator.hasNext()) {
sb.append(", ")
}
}
sb.append("}")
return sb.toString()
}

Named Arguments

使用 kotlin 提供的 joinToString() 完成任务1,只指定 joinToString() 的参数。

kotlin 中函数参数可以有默认值,当省略相应的参数时使用默认值。与其他语言相比,这可以减少重载数量:

默认值通过类型后面的=及给出的值定义。

1
fun read(b: Array<Byte>, off: Int = 0, len: Int = b.size) { …… }

重写一个有默认参数的函数时,我们不允许重新指定默认参数的值。

1
2
3
4
5
6
7
open class A {
open fun foo(i: Int = 10) { …… }
}

class B : A() {
override fun foo(i: Int) { …… } // 不能有默认值
}

可以在调用函数时使用命名的函数参数。当一个函数有大量的参数或默认参数时这会非常方便。

回到任务本身,先看一下函数 joinToString

1
2
3
public fun <T> Iterable<T>.joinToString(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "", limit: Int = -1, truncated: CharSequence = "...", transform: ((T) -> CharSequence)? = null): String {
return joinTo(StringBuilder(), separator, prefix, postfix, limit, truncated, transform).toString()
}

该函数对分隔符,前缀,后缀等其他参数都指定了默认值,我们只需要重新指定前缀、后缀两个参数。

1
2
3
fun task2(collection: Collection<Int>): String {
return collection.joinToString(postfix = "}",prefix = "{")
}

Default Arguments

使用参数默认值,修改foo函数,实现 Java 中需要重载才能实现的功能。

1
2
3
4
5
6
7
8
9
10
11
fun foo(name: String, number: Number = 42, toUpperCase: Boolean = false): String {
return (if (toUpperCase) name.toUpperCase() else name) + number
}

fun task3(): String {

return (foo("a") +
foo("b", number = 1) +
foo("c", toUpperCase = true) +
foo(name = "d", number = 2, toUpperCase = true))
}

非常简洁。这里还可以看出 if 是一个表达式,即它会返回一个值。 因此就不需要三元运算符(条件 ? 然后 : 否则)

Lambdas

使用 Lambda 重写 Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
// java
public boolean task4(Collection<Integer> collection) {
return Iterables.any(collection, new Predicate<Integer>() {
@Override
public boolean apply(Integer element) {
return element % 2 == 0;
}
});
}

// kotlin
fun task4(collection: Collection<Int>): Boolean = collection.any { x -> x % 2 == 0 }

Lambda 表达式的特征:

  • Lambdas 表达式是花括号括起来的代码块。
  • 如果一个 lambda 表达式有参数,前面是参数,后跟->
  • 函数体写在->符号后面。

String Templates

生成一个正则表达式,可以匹配13 JUN 1992 这样格式的字符串。

kotlin 中支持两种字符串格式:

一种是一对"包起来的字符串,支持转义字符。

1
val s = "Hello, world!\n"

一种是一对 """包起来的字符串,不需要用\来转义,可以直接使用各种符号,包括换行符。

1
2
3
4
val text = """
for (c in "foo")
print(c)
"""

字符串字面值可以包含模板表达式 ,即一些小段代码,会求值并把结果合并到字符串中。 模板表达式以美元符($)开头,由一个简单的名字构成:

1
2
val i = 10
println("i = $i") // 输出“i = 10”

回到任务,修改模板字符串,使其可以匹配测试中的日期格式。

1
2
val month = "(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)"
fun task5(): String = """\d{2}\ $month\ \d{4}"""

Data Classes

将 Java 中的数据实体类转化成 Kotlin。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// java
public static class Person {
private final String name;
private final int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

// kotlin
data class Person(val name: String, val age: Int)

kotlin 中使用data标记的类,编译器会自动根据主构造函数中定义的属性生成下面这些成员函数:

  • equals()/hashCode() 对;
  • toString() 格式是 "Person(name=John, age=42)"
  • componentN()按声明顺序对应于所有属性;
  • copy() 函数。

Nullable Type

将 Java 版本的 sendMessageToClient 方法使用 kotlin 改写,只能使用 if 表达式。

1
2
3
4
5
6
7
8
9
10
11
public void sendMessageToClient(@Nullable Client client, @Nullable String message, @NotNull Mailer mailer) {
if (client == null || message == null) return;

PersonalInfo personalInfo = client.getPersonalInfo();
if (personalInfo == null) return;

String email = personalInfo.getEmail();
if (email == null) return;

mailer.sendMessage(email, message);
}

kotlin 中,如果一个变量可能为空,在定义的时候需要在类型后面加上 ?

1
val a: Int? = null

对于可能为空的变量,如果必须在其值不为 null 时才进行后续操作,可以使用 ?.操作符进行保护。

1
a?.toLong()

如果变量为空,想要提供一个替代值,可以使用 ?:

1
val b = a?.toLong() ?: 0L

回到任务本身。

1
2
3
4
5
6
7
8
fun sendMessageToClient(
client: Client?, message: String?, mailer: Mailer
) {
val email = client?.personalInfo?.email
if (email != null && message != null) {
mailer.sendMessage(email,message)
}
}

Smart Casts

使用 kotlin 中 的Smart Cast 和 when表达式重新实现 Java 代码中的 eval() 函数。

1
2
3
4
5
6
7
8
9
10
public int eval(Expr expr) {
if (expr instanceof Num) {
return ((Num) expr).getValue();
}
if (expr instanceof Sum) {
Sum sum = (Sum) expr;
return eval(sum.getLeft()) + eval(sum.getRight());
}
throw new IllegalArgumentException("Unknown expression");
}

kotlin 中使用when代替了switch,功能更强大。

when 中,我们使用 -> 表示执行某一分支后的操作,-> 之前是条件,可以是任何表达式。

1
2
3
4
5
6
7
8
9
10
11
when (x) {
0, 1 -> print("x == 0 or x == 1")
else -> print("otherwise")
}

when (x) {
in 1..10 -> print("x is in the range")
in validNumbers -> print("x is valid")
!in 10..20 -> print("x is outside the range")
else -> print("none of the above")
}

回到任务本身。

1
2
3
4
5
6
fun eval(e: Expr): Int =
when (e) {
is Num -> e.value
is Sum -> eval(e.left) + eval(e.right)
else -> throw IllegalArgumentException("Unknown expression")
}

Extension Functions

kotlin 支持为已经存在的类编写扩展方法,而且不需要对原有类进行任何改动,只需要使用className.extensionFun的形式就可以了

1
fun String.lastChar() = this.get(this.length - 1)

任务要求为IntPair<Int, Int>分别实现一个扩展函数r()r()函数的功能就是创建一个有理数。

1
2
3
4
data class RationalNumber(val numerator: Int, val denominator: Int)

fun Int.r(): RationalNumber = RationalNumber(this, 1)
fun Pair<Int, Int>.r(): RationalNumber = RationalNumber(this.first, this.second)

Object Expression

任务的要求是创建一个比较器(comparator),提供给 Collection 类对 list 按照降序排序。

1
2
3
4
5
6
7
8
9
fun task10(): List<Int> {
val arrayList = arrayListOf(1, 5, 2)
Collections.sort(arrayList, object : Comparator<Int> {
override fun compare(o1: Int, o2: Int): Int {
return o2 - o1
}
})
return arrayList
}

SAM Conversions

SAM conversions 就是如果一个 object 实现了一个 SAM(Single Abstract Method)接口时,可以直接传递一个 lambda 表达式。

1
2
3
4
5
fun task11(): List<Int> {
val arrayList = arrayListOf(1, 5, 2)
arrayList.sortWith(Comparator { x, y -> y - x })
return arrayList
}

Extensions On Collections

使用扩展函数sortedDescending重写上一个任务中的代码:

1
2
3
fun task12(): List<Int> {
return arrayListOf(1, 5, 2).sortedDescending()
}

Collections

这一模块主要是集合的一些任务,所有任务都是围绕一个商店(Shop)展开,商店有一个客户(Customer)列表。

客户具有姓名、城市和订单(Order)列表三个属性。

订单具有商品(Product)列表和是否已经发货两个属性。

商品具有名称和价格两个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data class Shop(val name: String, val customers: List<Customer>)

data class Customer(val name: String, val city: City, val orders: List<Order>) {
override fun toString() = "$name from ${city.name}"
}

data class Order(val products: List<Product>, val isDelivered: Boolean)

data class Product(val name: String, val price: Double) {
override fun toString() = "'$name' for $price"
}

data class City(val name: String) {
override fun toString() = name
}

Introduction

要求返回一个 Set,包含商店中所有的客户:

1
2
3
4
5
fun Shop.getSetOfCustomers(): Set<Customer> {
// Return a set containing all the customers of this shop
return customers.toSet()
// return this.customers
}

Filter Map

filter 方法返回一个包含所有满足指定条件元素的列表。

任务第一个要求返回指定城市所有客户的列表。

1
2
3
4
fun Shop.getCustomersFrom(city: City): List<Customer> {
// Return a list of the customers who live in the given city
return customers.filter { it.city == city }
}

map 方法将指定的转换函数运用到原始集合的每一个元素,并返回一个转换后的集合。

任务第二个要求返回所有客户所在城市的 Set。

1
2
3
4
fun Shop.getCitiesCustomersAreFrom(): Set<City> {
// Return the set of cities the customers are from
return customers.map { it.city }.toSet()
}

All Any and others Predicates

all:如果所有的元素都满足指定的条件返回 true。

any:如果至少有一个元素满足指定的条件返回 ture。

count:返回满足指定条件的元素数量。

firstOrAll:返回第一个满足指定条件的元素,如果没有就返回 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
fun Customer.isFrom(city: City): Boolean {
// Return true if the customer is from the given city
return this.city == city
}

fun Shop.checkAllCustomersAreFrom(city: City): Boolean {
// Return true if all customers are from the given city
return customers.all { it.isFrom(city) }
}

fun Shop.hasCustomerFrom(city: City): Boolean {
// Return true if there is at least one customer from the given city
return customers.any { it.isFrom(city) }
}

fun Shop.countCustomersFrom(city: City): Int {
// Return the number of customers from the given city
return customers.count { it.isFrom(city) }
}

fun Shop.findFirstCustomerFrom(city: City): Customer? {
// Return the first customer who lives in the given city, or null if there is none
return customers.firstOrNull { it.isFrom(city) }
}

FlatMap

flatmap:针对列表中的每一项根据指定的方法生成一个列表,最后将所有的列表拼接成一个列表返回。

1
2
3
4
5
6
7
8
9
10
11
12
// 返回一个客户所有已订购的产品
val Customer.orderedProducts: Set<Product>
get() {
// Return all products this customer has ordered
return orders.flatMap { it.products }.toSet()
}
// 返回所有至少被一个客户订购过的商品集合
val Shop.allOrderedProducts: Set<Product>
get() {
// Return all products that were ordered by at least one customer
return customers.flatMap { it.orderedProducts }.toSet()
}

Max Min

max:返回集合中最大的元素。如果没有元素则返回 null。

maxby:使用函数参数计算的值作为比较对象。返回最大的元素中的值。

1
2
3
4
5
6
7
8
9
10
11
// 返回商店中订单数目最多的一个客户。
fun Shop.getCustomerWithMaximumNumberOfOrders(): Customer? {
// Return a customer whose order count is the highest among all customers
return customers.maxBy { it.orders.size }
}

// 返回一个客户所订购商品中价格最高的一个商品
fun Customer.getMostExpensiveOrderedProduct(): Product? {
// Return the most expensive product which has been ordered
return orders.flatMap { it.products }.maxBy { it.price }
}

Sort

kotlin 中可以通过 sortby指定排序的标准。

任务要求返回一个客户列表,客户的顺序是根据订单的数量由低到高。

1
2
3
4
fun Shop.getCustomersSortedByNumberOfOrders(): List<Customer> {
// Return a list of customers, sorted by the ascending number of orders they made
return customers.sortedBy { it.orders.size }
}

Sum

sumby:将集合中所有元素按照指定的函数变换以后的结果累加。

任务要求计算一个客户所有已订购商品的价格总和。

1
2
3
4
5
fun Customer.getTotalOrderPrice(): Double {
// Return the sum of prices of all products that a customer has ordered.
// Note: a customer may order the same product several times.
return orders.flatMap { it.products }.sumByDouble { it.price }
}

GroupBy

groupBy方法返回一个根据指定条件分组好的 map。

任务要求返回来自每一个城市的客户的 map:

1
2
3
4
fun Shop.groupCustomersByCity(): Map<City, List<Customer>> {
// Return a map of the customers living in each city
return customers.groupBy { it.city }
}

Partition

partition:将原始的集合分为一对集合,这一对集合中第一个是满足指定条件的元素集合,第二个是不满足指定条件的集合。

任务要求返回所有未发货订单数目多于已发货订单的用户。

1
2
3
4
5
6
7
fun Shop.getCustomersWithMoreUndeliveredOrdersThanDelivered(): Set<Customer> {
// Return customers who have more undelivered orders than delivered
return customers.filter {
val (delivered, undelivered) = it.orders.partition { it.isDelivered }
delivered.size < undelivered.size
}.toSet()
}

Fold

fold:给定一个初始值,然后通过迭代对集合中的每一个元素执行指定的操作并将操作的结果累加。

任务要求返回所有客户都订购过的商品。

1
2
3
4
5
fun Shop.getSetOfProductsOrderedByEachCustomer(): Set<Product> {
// Return the set of products that were ordered by each of the customers
return customers.fold(allOrderedProducts, { orderedByAll, customer ->
orderedByAll.intersect(customer.orderedProducts)
})

CompoundTasks

上述方法的混合使用。

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

fun Customer.hasOrderedProduct(product: Product) = orders.any { it.products.contains(product) }

// 所有购买了指定商品的客户列表
fun Shop.getCustomersWhoOrderedProduct(product: Product): Set<Customer> {
// Return the set of customers who ordered the specified product
return customers.filter { it.hasOrderedProduct(product) }.toSet()
}

// 查找某个用户所有已发货的商品中最昂贵的商品
fun Customer.getMostExpensiveDeliveredProduct(): Product? {
// Return the most expensive product among all delivered products
// (use the Order.isDelivered flag)
return orders.filter { it.isDelivered }.flatMap { it.products }.maxBy { it.price }
}

// 查找指定商品被购买的次数
fun Shop.getNumberOfTimesProductWasOrdered(product: Product): Int {
// Return the number of times the given product was ordered.
// Note: a customer may order the same product for several times.
return customers.flatMap { it.orders.flatMap { it.products } }.count { it == product }
}

Extensions On Collections

重写 Java 代码

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
public Collection<String> doSomethingStrangeWithCollection(Collection<String> collection) {
Map<Integer, List<String>> groupsByLength = Maps.newHashMap();
for (String s : collection) {
List<String> strings = groupsByLength.get(s.length());
if (strings == null) {
strings = Lists.newArrayList();
groupsByLength.put(s.length(), strings);
}
strings.add(s);
}

int maximumSizeOfGroup = 0;
for (List<String> group : groupsByLength.values()) {
if (group.size() > maximumSizeOfGroup) {
maximumSizeOfGroup = group.size();
}
}

for (List<String> group : groupsByLength.values()) {
if (group.size() == maximumSizeOfGroup) {
return group;
}
}
return null;
}

方法的目的是:

  • 将一个字符串集合按照长度分组,放入一个 map 中
  • 求出 map 中所有元素 (String List) 的最大长度
  • 根据步骤2的结果,返回 map 中字符串数目最多的那一组
1
2
3
4
fun doSomethingStrangeWithCollection(collection: Collection<String>): Collection<String>? {
val groupsByLength = collection.groupBy { s -> s.length }
return groupsByLength.values.maxBy { group -> group.size }
}

Conventions

Comparsion

实现日期对象大小的比较

1
2
3
fun task25(date1: MyDate, date2: MyDate): Boolean {
return date1 < date2
}

其中类 MyDate需要实现 Comparable接口。

1
2
3
4
5
6
7
8
9
data class MyDate(val year: Int, val month: Int, val dayOfMonth: Int) : Comparable<MyDate> {
override fun compareTo(other: MyDate) =
when {
other.year != year -> year - other.year
other.month != month -> month - other.month
else -> dayOfMonth - other.dayOfMonth
}

}

InRange

实现检查指定的日期是不是在某一个日期范围内。

1
2
3
fun checkInRange(date: MyDate, first: MyDate, last: MyDate): Boolean {
return date in DateRange(first, last)
}

DateRange 类已经定义好,需要添加 contains函数。

1
2
3
class DateRange(val start: MyDate, val endInclusive: MyDate) {
operator fun contains(value: MyDate): Boolean = start <= value && value <= endInclusive
}

RangeTo

这一个任务是要求实现MyDate类的..运算符。..运算符最终会翻译成rangeTo()函数,所以本任务就是实现MyDate.rangeTo()

1
2
3
4
5
6
operator fun MyDate.rangeTo(other: MyDate): DateRange = DateRange(this, other)

fun checkInRange2(date: MyDate, first: MyDate, last: MyDate): Boolean {
//todoTask27()
return date in first..last
}

ForLoop

任务要求对 DateRange 内的 MyDate 执行 for 循环,因此 DateRange 需要实现 Iterable 接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DateRange(val start: MyDate, val endInclusive: MyDate) : Iterable<MyDate> {
override fun iterator(): Iterator<MyDate> = DateIterator(this)

operator fun contains(value: MyDate): Boolean = start <= value && value <= endInclusive
}

class DateIterator(val dateRange: DateRange) : Iterator<MyDate> {
var current: MyDate = dateRange.start
override fun hasNext(): Boolean = current <= dateRange.endInclusive

override fun next(): MyDate {
val result = current
current = current.nextDay()
return result
}
}

OperatorOverloading

重载 +运算符:

1
operator fun MyDate.plus(timeInterval: TimeInterval) = addTimeIntervals(timeInterval, 1)

重载*运算符:

1
2
3
4
5
6
// 将TimeInterval的乘法结果定义成RepeatedTimeInterval
class RepeatedTimeInterval(val timeInterval: TimeInterval, val number: Int)
operator fun TimeInterval.times(number: Int) = RepeatedTimeInterval(this, number)

operator fun MyDate.plus(timeIntervals: RepeatedTimeInterval) = addTimeIntervals(timeIntervals.timeInterval, timeIntervals.number)

Destructuring Declaration

kotlin 可以将一个对象的所有属性一次赋值给一堆变量

任务要求判断一个日期是否是闰年。

1
2
3
4
5
6
7
8
9
10
11
data class MyDate(val year: Int, val month: Int, val dayOfMonth: Int)

fun isLeapDay(date: MyDate): Boolean {
val (year, month, dayOfMonth) = date

// 29 February of a leap year
return isLeapYear(year) && month == 1 && dayOfMonth == 29
}

// Years which are multiples of four (with the exception of years divisible by 100 but not by 400)
fun isLeapYear(year: Int): Boolean = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)

Invoke

如果一个类实现了invoke函数,那么该类的实体对象在调用这个函数时可以省略函数名,invoke函数需要有operator修饰符。

1
2
3
4
5
6
7
8
9
10
11
fun task31(invokable: Invokable): Int {
return invokable()()()().numberOfInvocations
}

class Invokable {
var numberOfInvocations: Int = 0
operator fun invoke(): Invokable {
numberOfInvocations++
return this
}
}

Properties

Properties

kotlin 中可以为属性自定义 setter,格式如下:

1
2
3
4
5
6
7
8
class PropertyExample() {
var counter = 0
var propertyWithCounter: Int? = null
set(value) {
field = value
counter++
}
}

每次为 propertyWithCounter 赋值时,都会调用 set,kotlin 中不能直接声明字段,然而,当一个属性需要一个幕后字段时,Kotlin 会自动提供。这个幕后字段可以使用field标识符在访问器中引用。

LazyProperty

initializer是一个lambda 表达式,这个表达式会在lazy属性第一次被访问的时候执行,且仅执行一次

1
2
3
4
5
6
7
8
9
10
class LazyProperty(val initializer: () -> Int) {
var value: Int? = null
val lazy: Int
get() {
if (value == null) {
value = initializer()
}
return value!!
}
}

DelegatesExamples

用委托实现懒加载

1
2
3
class LazyPropertyUsingDelegates(val initializer: () -> Int) {
val lazyValue: Int by lazy(initializer)
}

HowDelegatesWork

1
2
3
4
5
6
class EffectiveDate<R> : ReadWriteProperty<R, MyDate> {
var timeInMillis: Long? = null

operator override fun getValue(thisRef: R, property: KProperty<*>): MyDate = timeInMillis!!.toDate()
operator override fun setValue(thisRef: R, property: KProperty<*>, value: MyDate) { timeInMillis = value.toMillis() }
}

Builders

ExtensionFunctionLiterals

1
2
3
4
5
6
fun task36(): List<Boolean> {
val isEven: Int.() -> Boolean = { this % 2 == 0 }
val isOdd: Int.() -> Boolean = { this % 2 != 0 }

return listOf(42.isOdd(), 239.isOdd(), 294823098.isEven())
}

StringAndMapBuilders

类型扩展函数,仿照buildString实现buildMap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun <K, V> buildMap(build: MutableMap<K, V>.() -> Unit): Map<K, V> {
val map = HashMap<K, V>()
map.build()
return map
}

fun task37(): Map<Int, String> {
return buildMap {
put(0, "0")
for (i in 1..10) {
put(i, "$i")
}
}
}

TheFunctionApply

使用apply重写上一练习中的功能

1
2
3
4
fun <T> T.myApply(f: T.() -> Unit): T {
f()
return this
}

HtmlBuilders

products填充进表格,并设置好背景色,运行htmlDemo.kt可以预览内容

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
fun renderProductTable(): String {
return html {
table {
tr {
td {
text("Product")
}
td {
text("Price")
}
td {
text("Popularity")
}
}
val products = getProducts()
for ((index, product) in products.withIndex()) {
tr {
td (color = getCellColor(index, 0)) {
text(product.description)
}
td (color = getCellColor(index, 1)) {
text(product.price)
}
td (color = getCellColor(index, 2)) {
text(product.popularity)
}
}
}
}
}.toString()
}

BuildersHowItWorks

1:c,td是一个方法,这里的td显然是在调用
2:b,color是参数名,这里使用了命名参数
3:b,这里是个lambda表达式
4:c,this指向调用者

Generics

GenericsFunctions

根据条件将一个集合分为两个集合。

1
2
3
4
5
6
7
8
9
10
fun <T, C: MutableCollection<T>> Collection<T>.partitionTo(first: C, second: C, predicate: (T) -> Boolean): Pair<C, C> {
for (element in this) {
if (predicate(element)) {
first.add(element)
} else {
second.add(element)
}
}
return Pair(first, second)
}