Android嵌套滚动

Android Design包通过提供NestedScrollingChild和NestedScrollingParent来帮助开发实现嵌套滑动效果

NestedScrollingChild与NestedScrollingParent关系简述

NestedScrollingChild NestedScrollingParent 备注
dispatchNestedScroll onNestedScroll 分发嵌套滑动事件,在子View滑动处理完之后调用, unconsumed表示未被子View滚动消费的距离, consumed表示被子View消费的滚动距离
startNestedScroll onStartNestedScroll 前者的调用会触发后者的调用,然后后者的返回值将决定后续的嵌套滑动事件是否能传递给父View,如果返回false,父View将不处理嵌套滑动事件,一般前者的返回值即后者的返回值
onNestedScrollAccepted 如果onStartNestedScroll返回true,则回调此方法
stopNestedScroll onStopNestedScroll
dispatchNestedPreScroll onNestedPreScroll 分发预嵌套滑动事件,在子View滑动处理之前调用, 通过consumed数组得到NestedScrollingParent消耗掉的滚动距离
dispatchNestedFling onNestedFling
dispatchNestedPreFling onNestedPreFling
getNestedScrollAxes 获得滑动方向,没有回调,为主动调用的方法

举例说明: SwipeRefreshLayout && RecyclerView嵌套

本例子中,SwipeRefreshLayout为NestedScrollingParent,而RecyclerView为NestedScrollingChild

根据触摸事件分发机制,ACTION_DOWN首先会来到SwipeRefreshLayout#onInterceptTouchEvent, 而SwipeRefreshLayout未做拦截,因此来到RecyclerView#onTouchEvent, 而RecyclerView#onTouchEvent针对ACTION_DOWN返回true;之后的所有事件都会传递到RecyclerView中,之后我们看下RecyclerView中对ACTION_MOVE事件的处理

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

case MotionEvent.ACTION_MOVE: {
// ...

if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
dx -= mScrollConsumed[0]; // 得到parent消费滚动后的剩余y距离
dy -= mScrollConsumed[1]; // 得到parent消费滚动后的剩余x距离
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
}
// ...

if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];

if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;




关注这里的dispatchNestedPreScrollscrollByInternal方法, dispatchNestedPreScroll最终会触发SwipeRefreshLayout#onNestedPreScroll方法,scrollByInternal会调用dispatchNestedScroll方法,最终也来到SwipeRefreshLayout#onNestedScroll

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

@Override
public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
final int dxUnconsumed, final int dyUnconsumed) {
//将nestedScroll传递给Parent
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
mParentOffsetInWindow);

final int dy = dyUnconsumed + mParentOffsetInWindow[1];

if (dy < 0 && !canChildScrollUp()) { //注意canChildScrollUp方法
mTotalUnconsumed += Math.abs(dy);

moveSpinner(mTotalUnconsumed); //处理refreshView的滑动
}
}

@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
//只有当refreshView已经出现在屏幕中,并且手指往上移动才会调用下面的代码
if (dy > 0 && mTotalUnconsumed > 0) {
if (dy > mTotalUnconsumed) {
consumed[1] = dy - (int) mTotalUnconsumed;
mTotalUnconsumed = 0;
} else {
mTotalUnconsumed -= dy;
consumed[1] = dy; //消耗掉的距离, 这里回返回给RecyclerView
}
moveSpinner(mTotalUnconsumed);//处理refreshView的滑动
}

// refreshView移除屏幕
if (mUsingCustomStart && dy > 0 && mTotalUnconsumed == 0
&& Math.abs(dy - consumed[1]) > 0) {
mCircleView.setVisibility(View.GONE);
}

// 将nestedPreScroll传递到Parent去(本文可以忽略)
final int[] parentConsumed = mParentScrollConsumed;
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
consumed[0] += parentConsumed[0];
consumed[1] += parentConsumed[1];
}
}

嵌套滚动时一个核心问题是当手指滑动时,这个滑动的距离由谁消费?为了解决这个问题,NestedScrollingParent引入了consumed这个概念,通过一个consumed数组的引用,可以告知上层View消费掉了多少距离,而子View则可以根据这个消费掉的距离,以及滑动的总距离,来处理自己的滑动距离

在本例子中,手指移动的距离 = refreshView滑动的距离 + RecyclerView滑动的距离

如何在代码中实现嵌套滚动效果

对一个NestedScrollingChild首先调用startNestedScroll,之后调用dispatchNestedScroll即可

1
2
3
4
5
6
7
8
recyclerView.startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
val arr = IntArray(2)
recyclerView.dispatchNestedPreScroll(0, offsetY, arr, null)
val dyUnconsumed = offsetY - arr[1]
if (dyUnconsumed != 0) {
recyclerView.scrollBy(0, dyUnconsumed)
}

FFMpeg-Android开发-简单音视频同步

本文代码可参考代码可以参考https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v2.1

我们之前实现了视频和音频的播放,但其中最大的问题是我们的音频和视频之间的播放速度没有同步,视频按照解码的速度,以最快速度进行了上屏,那么很有可能会出现视频播放完后音频还在播放的情况。这次我们就来尝试解决这个问题,正式解决问题前,我们先对一些基本概念做出介绍

FFMpeg 中的 dts 和 pts

FFmpeg 里有两种时间戳:DTS(Decoding Time Stamp)和 PTS(Presentation Time Stamp)。前者是解码的时间,后者是显示的时间。要仔细理解这两个概念,需要先了解 FFmpeg 中的 packet 和 frame 的概念。

FFmpeg 中用 AVPacket 结构体来描述解码前或编码后的压缩包,用 AVFrame 结构体来描述解码后或编码前的信号帧。 对于视频来说,AVFrame 就是视频的一帧图像。这帧图像什么时候显示给用户,就取决于它的 PTS。DTS 是 AVPacket 里的一个成员,表示这个压缩包应该什么时候被解码。 如果视频里各帧的编码是按输入顺序(也就是显示顺序)依次进行的,那么解码和显示时间应该是一致的。可事实上,在大多数编解码标准(如 H.264 或 HEVC)中,编码顺序和输入顺序并不一致。 于是才会需要 PTS 和 DTS 这两种不同的时间戳。

具体到代码中为

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
AVPacket *packet = av_packet_alloc();
av_init_packet(packet);


// packet->dts == AV_NOPTS_VALUE
while (av_read_frame(format_context, packet) >= 0) {
// do decoding ...
// packet->dts != AV_NOPTS_VALUE here

result = avcodec_send_packet(video_codec_context, packet);
if (result < 0 && result != AVERROR(EAGAIN) && result != AVERROR_EOF) {
LOGE("Player Error : codec step 1 fail");
return;
}
result = avcodec_receive_frame(video_codec_context, frame);
if (result < 0 && result != AVERROR_EOF) {
LOGE("Player Error : codec step 2 fail");
return;
}
LOGI("Frame %c (%d, size=%d) pts %d dts %d key_frame %d [codec_picture_number %d, display_picture_number %d]",
av_get_picture_type_char(frame->pict_type), video_codec_context->frame_number,
frame->pkt_size,
frame->pts,
frame->pkt_dts,
frame->key_frame, frame->coded_picture_number, frame->display_picture_number);
}

那么为什么要区分 DTS 和 PTS 呢?这里就涉及到编解码标准中编码顺序和输入顺序不一致的问题,这里以 H.264 规范为例举一个例子

假设有一个解码帧序列为IBP,当解码器对其解码时,解码第一帧肯定是序列中的第一帧 I, 但我们思考一下第二帧,已知 B 帧的解码需要参考前一个 I 帧或 P 帧以及后面一个 P 帧,那么解码的第二帧显然不能是序列中的第二帧 B 帧,而是第三帧 P 帧,因此我们得到了下面的一个 PTS 和 DTS 关系

PTS: 1 2 3
DTS: 1 3 2

这里我们就会发现,DTS 和 PTS 是不同的

DTS 主要用于视频的解码,在解码阶段使用.PTS 主要用于视频的同步和输出.在 display 的时候使用.在没有 B frame 的情况下.DTS 和 PTS 的输出顺序是一样的.

Timebase

在 FFMpeg 中,同时还引入了 timebase 这个概念。timebase 用来度量时间尺度,假设 timebase={1, 25}, 那么意味着时间尺度就是 1/25 秒,假设 pts=20,那么在 timebase={1, 25}的情况下。这一帧的时间为(20 * 1/25)s

FFMpeg 中,不同的数据状态对应的 timebase 也不一致。例如,非压缩时的数据(即 YUV 或者其它),在 ffmpeg 中对应的结构体为 AVFrame,它的 timebase 为 AVCodecContext 的 time_base ,AVRational{1,25}。
压缩后的数据(对应的结构体为 AVPacket)对应的 timebase 为 AVStream 的 time_base,AVRational{1,90000}。
因为数据状态不同,timebase 不一样,所以我们必须转换,在 1/25 时间刻度下佔 10 格,在 1/90000 下是佔多少格。这就是 pts 的转换。

利用 pts 实现音视频同步

当我们得到 timebase 和 pts 数据后,我们就可以通过时间换算来同步视频和音频的播放,考虑到音频的播放速度固定,最简单的做法就是将视频的播放向音频同步。

我们需要定义一个audio_clock来记录音频播放的时钟

1
2
3
4
5
if (index == player->audio_stream_index) {
player->audio_clock = packet->pts * av_q2d(stream->time_base);
LOGD("SyncPlayer: Playing audio loop");
audio_play(player, frame, env);
}

然后在视频播放的时候利用这个audio_clock进行一定的 delay

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (index == player->video_stream_index) {
auto audio_clock = player->audio_clock;
double timestamp;
if (packet->pts == AV_NOPTS_VALUE) {
timestamp = 0;
} else {
timestamp = frame->best_effort_timestamp * av_q2d(stream->time_base);
}
double frame_rate = av_q2d(stream->avg_frame_rate); // fps = 1 / stream->avg_frame_rate
frame_rate += frame->repeat_pict * (frame_rate * 0.5); // repeat_dict代表当前frame必须delay的时间, extra_delay = repeat_pict / (2*fps)
if (timestamp == 0.0) {
usleep(frame_rate * 1000);
} else {
if (fabs(timestamp - audio_clock) > AV_SYNC_THRESHOLD_MIN
&& fabs(timestamp - audio_clock) < AV_NOSYNC_THRESHOLD) {
if (timestamp > audio_clock) {
usleep((unsigned long)((timestamp - audio_clock)*1000000));
}
}
}
video_play(player, frame, env);
}

至此,一个简单地音视频播放器就完成了.

Kotlin协程异常处理

Kotlin的协程满足结构化语义:

  1. A parent-Coroutine finishes only after all its child-Coroutines have finished.

  2. When a parent-Coroutine or scope finishes abnormally, either through cancelation or through an exception, all its child-Coroutines, that are still active, are canceled and new child-Coroutines can no longer be launched.

  3. When a child-Coroutine finishes abnormally, its parent-Coroutine or scope finishes abnormally.

针对第三点,无论是CoroutineScope.asyncCoroutineScope.launch,以异常结束,只要是在一个scope中,他的父协程也会以异常结束。asynclaunch唯一不同的地方在于async需要调用await才会执行协程内容。即使我们对子协程进行了try-catch处理异常,父协程仍旧会拿着这个异常作为结果结束

async的异常处理

不管是哪个启动器(launch, async等),在应用了作用域之后,都会按照**作用域的语义**进行异常扩散,进而触发相应的取消操作,对于 async 来说就算不调用 await 来获取这个异常,它也会在 coroutineScope 当中触发父协程的取消逻辑,这一点请大家注意。

以CoroutineScope(Dispatchers.Main)作为根作用域为例,下面展示几个case

1
2
3
4
5
6
CoroutineScope(Dispatchers.Main).launch {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}

上面的代码即使没有调用foo.await(), 也会扩散到Thread.UncaughtExceptionHandler中。这段代码的执行结果是

D/Foo: [, , 0]:test

同时app crash

这里我们应该这么理解,虽然是对await()进行了try-catch,但这里是对执行结果的try-catch, 这不影响协程自己的异常传递规则,在async中的协程scope抛出异常后,此时异常是未捕获状态;因此会向父协程scopecoroutineScope转播, coroutineScope继续向viewModelScope传播,最终来到UncaughtExceptionHandler处处理

那么考虑一下直接加入try-catch:

1
2
3
4
5
6
7
8
9
10
11
12
CoroutineScope(Dispatchers.Main).launch {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e : IllegalStateException) {
Log.e("Foo", "CoroutineScope caught exception: $e")
}
Log.d("Foo", "test")
}

输出结果为:

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

同时app crash

这里虽然我们对await进行了try-catch,打印了异常信息,但是根据作用域规则,这里我们的异常行为发生了扩散,从子协程扩散到根协程,最终扩散到Thread.UncaughtExceptionHandler中,对于安卓系统而言,就是引发了crash

那么如果我们在扩散的scope外层进行try-catch能否解决问题呢?尝试如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
CoroutineScope(Dispatchers.Main).launch {
try {
coroutineScope {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from outerScope: $e")
}
}

输出结果为:

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

此时app 不再crash

如果我们在添加await()同时进行try-catch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CoroutineScope(Dispatchers.Main).launch {
try {
coroutineScope {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from innerScope: $e")
}
Log.d("Foo", "test")
}
} catch (e: IllegalStateException) {
Log.e("Foo", "catch expection from outerScope: $e")
}
}

输出结果为:

E/Foo: [, , 0]:catch expection from innerScope: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test
E/Foo: [, , 0]:catch expection from outerScope: java.lang.IllegalStateException: test

此时app 也不再crash,但我们会发现我们的两处try-catch均被触发

这也说明在协程中,异常的扩散并不遵循try-catch语法构成的作用域

另一种方式就是换用一个不进行扩散语义的协程作用域,即使用supervisorScope

1
2
3
4
5
6
7
8
9
10
CoroutineScope(Dispatchers.Main).launch {
supervisorScope {
val foo = async {
throw IllegalStateException("test")
}
Log.d("Foo", "test")
}

}

这段代码的输出结果为

D/Foo: [, , 0]:test

app 不发生crash, 同时没有IllegalStateException(“test”)的日志打印

这里的核心在于,我们的async是在supervisorScope作用域下,根据文档描述,这个作用域下的协程出现取消情况(异常抛出时)不会向外扩散

假如加上try-catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CoroutineScope(Dispatchers.Main).launch {
supervisorScope {
val foo = async {
throw IllegalStateException("test")
}
try {
foo.await()
} catch (e: IllegalStateException) {
Log.e("Foo", "CoroutineScope caught exception: $e")
}
Log.d("Foo", "test")
}

}

我们得到结果

E/Foo: [, , 0]:CoroutineScope caught exception: java.lang.IllegalStateException: test
D/Foo: [, , 0]:test

app 不发生crash

同理,我们的async是在supervisorScope作用域下,根据文档描述,这个作用域下的协程出现取消情况(异常抛出时)不会向外扩散,因此不会向外扩散到Thread.UncaughtExceptionHandler。同时,try-catch对await()发生作用,我们打印出了异常的信息

withContext的异常处理

那么对比一下withContext的处理我们就会发现不同

1
2
3
4
5
6
7
8
9
viewModelScope.launch {
try {
withContext(Dispatchers.Default) {
throw IllegalArgumentException("Test")
}
} catch (e: Exception) {
}
}

这里withContext直接就是一个协程scope,我们的try-catch直接作用于整个withContext构造的scope,因此异常被捕获的同时,不再向viewModelScope传播

supervisorScope

如何能够让子协程抛出异常的情况下,父协程不会终止所以其子协程和自己呢?这就引入了supervisorScope

1
2
3
4
5
6
7
val result = supervisorScope {
...
val supervisedChild1 = this.launch { ... }
...
val supervisedChild2 = this.async { ... }
...
}

此时,若child1或child2任意一个抛出异常,也不会使另一个child和supervising parent停止

小结

最终我们得到了协程的Structured Concurrency含义:

  1. A parent-Coroutine finishes only after all its child-Coroutines have finished.

  2. When a parent-Coroutine or scope finishes abnormally, either through cancelation or through an exception, all its child-Coroutines, that are still active, are canceled and new child-Coroutines can no longer be launched.

  3. When a child-Coroutine finishes abnormally, its parent-Coroutine or scope (a) finishes abnormally if the parent is not a supervisor or (b) keeps running if the parent is a supervisor.

P.S 协程中的异常最佳实践

通过上文中对await,withContext的异常处理的方式,我们会发现在协程中处理异常其实是一件非常麻烦的事情,其异常的扩散规则并不合try-catch的作用域对应。因此这里简单提一下如何在实际项目中处理这种异常

Kotlin中对异常处理有两种推荐方式:

  1. default value
  2. Wrapper Seal class

第一种方式就是返回一个跟函数签名同类型的默认值,例如

1
2
3
4
5
6
7
8
fun toIntSafely(defaultValue: Int) : Int {
return try {
parseInt(this)
} catch (e: NumberFormatException) {
return defaultValue
}
}

第二种方式则是在API设计上使用一个Wrapper Class来包装出现的异常,使用这个Wrapper Class来保证Type-Check,举例来说:

1
2
3
4
5
6
7
8
9
10
11
12

sealed class ParsedDate {
data class Success(val date: Date) : ParsedDate()
data class Failure(val errorOffset: Int) : ParsedDate()
}

fun DateFormat.tryParse(text: String): ParsedDate =
try {
ParsedDate.Success(parse(text))
} catch (e: ParseException) {
ParsedDate.Failure(e.errorOffset)
}

那么对调用者而言,tryParse的结果一定是一个ParsedDate对象,而对异常无感知

结合协程使用来说,我们应该直接在一个作用域内,就返回一个default value或Wrapper Seal class,例如

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

sealed class ParsedDate {
data class Success(val date: Date) : ParsedDate()
data class Failure(val errorOffset: Int) : ParsedDate()
}


CoroutineScope(Dispatchers.Main).launch {
val foo = async {
try {
throw IllegalStateException("test")
} catch (e: IllegalStateException) {
Log.e("Foo", "catch exception inside: $e")
ParsedDate.Failure(-1)
}
}
val rst = foo.await()
Log.d("Foo", "return rst: ${rst.errorOffset}")
}

输出结果:

E/Foo: [, , 0]:catch exception inside: java.lang.IllegalStateException: test
D/Foo: [, , 0]:return rst: -1

同时app无crash,无异常日志打印

ref:

https://www.bennyhuo.com/2019/04/23/coroutine-exceptions/#4-%E5%BC%82%E5%B8%B8%E4%BC%A0%E6%92%AD

https://johnnyshieh.me/posts/kotlin-coroutine-exception-handling/

https://medium.com/@elizarov/structured-concurrency-722d765aa952

https://medium.com/the-kotlin-chronicle/coroutine-exceptions-supervision-15056802998b

https://medium.com/the-kotlin-chronicle/coroutine-exceptions-3378f51a7d33

https://github.com/Kotlin/kotlinx.coroutines/issues/753

FFMpeg-Android开发-简单播放音频

本文部分对应的源代码可参考https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v1.2

具体实现

音频播放的整个流程和视频非常相似,都经历了下面几个步骤

  1. 解析 container
  2. 根据 container,获得我们关心的媒体数据(例如播放视频,那我们只关心视频媒体)
  3. 根据媒体信息获得对应的解码器
  4. 将对应的数据送给解码器
  5. 解码器解码,输出帧
  6. 将帧渲染到目标区域(播放音频)

1-5 的步骤几乎可以参考播放视频的流程,这里简单看下不同

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

auto swr_context = swr_alloc();
auto out_buffer = (uint8_t *) av_malloc(44100 * 2);


// expected sample output
uint64_t out_channel_layout = AV_CH_LAYOUT_STEREO;
auto out_format = AV_SAMPLE_FMT_S16;

auto out_sample_rate = audio_codec_context->sample_rate;

// expected sample out para end

swr_alloc_set_opts(swr_context,
out_channel_layout, out_format, out_sample_rate,
audio_codec_context->channel_layout, audio_codec_context->sample_fmt, audio_codec_context->sample_rate,
0, nullptr);


swr_init(swr_context);

auto out_channels = av_get_channel_layout_nb_channels(AV_CH_LAYOUT_STEREO);

这一段的主要目的是配置音频的转码格式,跟视频不同,但基本逻辑一致

1
2
3
4
5
6
auto player_class = env->GetObjectClass(instance);
auto create_audio_track_method_id = env->GetMethodID(player_class, "createAudioTrack", "(II)V");
env->CallVoidMethod(instance, create_audio_track_method_id, 44100, out_channels);


auto play_audio_track_method_id = env->GetMethodID(player_class, "playAudioTrack", "([BI)V");

这里我们通过 JNI 来调用 Java 层的 AudioTrack 相关 API 来播放音频

至此,我们已经完成了音频的播放;接下来就剩下如何同时播放视频和音频,以及音视频同步问题了。下篇文章,我们就会来着手实现一个时间同步的简单播放器

FFMpeg-Android开发-简单播放视频

本文部分对应的源代码可参考https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v1.1

具体实现

结合上一篇文章FFMpeg-Android开发-解析视频格式的内容,我们知道视频解码大致分为如下几个步骤

  1. 解析 container
  2. 根据 container,获得我们关心的媒体数据(例如播放视频,那我们只关心视频媒体)
  3. 根据媒体信息获得对应的解码器
  4. 将对应的数据送给解码器
  5. 解码器解码,输出帧
  6. 将帧渲染到目标区域

那么这次我们就来尝试将视频解码并渲染到设备屏幕上,首先看我们 1-3 步的代码

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

// Record result
int result;
// R1 Java String -> C String
const char *path = env->GetStringUTFChars(path_, 0);
// Register FFmpeg components
av_register_all();
// R2 initializes the AVFormatContext context
AVFormatContext *format_context = avformat_alloc_context();
// Open Video File
result = avformat_open_input(&format_context, path, NULL, NULL);
if (result < 0) {
LOGE("Player Error : Can not open video file");
return;
}
// Finding Stream Information of Video Files
result = avformat_find_stream_info(format_context, NULL);
if (result < 0) {
LOGE("Player Error : Can not find video file stream info");
return;
}
// Find Video Encoder
int video_stream_index = -1;
for (int i = 0; i < format_context->nb_streams; i++) {
// Matching Video Stream
if (format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_index = i;
}
}
// No video stream found
if (video_stream_index == -1) {
LOGE("Player Error : Can not find video stream");
return;
}
// Initialization of Video Encoder Context
AVCodecContext *video_codec_context = avcodec_alloc_context3(NULL);
avcodec_parameters_to_context(video_codec_context, format_context->streams[video_stream_index]->codecpar);
// Initialization of Video Encoder
AVCodec *video_codec = avcodec_find_decoder(video_codec_context->codec_id);
if (video_codec == NULL) {
LOGE("Player Error : Can not find video codec");
return;
}
// R3 Opens Video Decoder
result = avcodec_open2(video_codec_context, video_codec, NULL);
if (result < 0) {
LOGE("Player Error : Can not find video stream");
return;
}
// Getting the Width and Height of Video
int videoWidth = video_codec_context->width;
int videoHeight = video_codec_context->height;

其中 video_codec 代表解码器对象,其他对象在上一篇文章和代码注释中有相关解释。

接下来的步骤就是解码和上屏,这里我们先看下我们的上屏是如何实现的;这里为了方便,我直接使用 SurfaceView 构建我们的上层 View

1
2
3
4
5
6
7
8
9
10
11
12
public fun onCreate(savedInstanceState: Bundle?) {
// ...
surfaceHolder = surfaceView.holder
surfaceHolder!!.setFormat(PixelFormat.RGBA_8888)
}

private fun playVideo(path: String) {
Thread {
mPlayer.playVideo(path, surfaceHolder!!.surface)
}.start()

}
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
// R4 Initializes Native Window s for Playing Videos
ANativeWindow *native_window = ANativeWindow_fromSurface(env, surface); // surface对应java层的surface对象
if (native_window == NULL) {
LOGE("Player Error : Can not create native window");
return;
}
// Limit the number of pixels in the buffer by setting the width, not the physical display size of the screen.
// If the buffer does not match the display size of the physical screen, the actual display may be stretched or compressed images.
result = ANativeWindow_setBuffersGeometry(native_window, videoWidth, videoHeight,WINDOW_FORMAT_RGBA_8888);
if (result < 0){
LOGE("Player Error : Can not set native window buffer");
ANativeWindow_release(native_window);
return;
}
// Define drawing buffer
ANativeWindow_Buffer window_buffer;
// There are three declarative data containers
// Data container Packet encoding data before R5 decoding
AVPacket *packet = av_packet_alloc();
av_init_packet(packet);
// Frame Pixel Data of Data Container After R6 Decoding Can't Play Pixel Data Directly and Need Conversion
AVFrame *frame = av_frame_alloc();
// R7 converted data container where the data can be used for playback
AVFrame *rgba_frame = av_frame_alloc();
// Data format conversion preparation
// Output Buffer
int buffer_size = av_image_get_buffer_size(AV_PIX_FMT_RGBA, videoWidth, videoHeight, 1);
// R8 Application for Buffer Memory
uint8_t *out_buffer = (uint8_t *) av_malloc(buffer_size * sizeof(uint8_t));
LOGI("outBuffer size: %d, videoWidth: %d, videoHeight: %d, pix_fmt: %d", buffer_size * sizeof(uint8_t), videoWidth, videoHeight, video_codec_context->pix_fmt);
av_image_fill_arrays(rgba_frame->data, rgba_frame->linesize, out_buffer, AV_PIX_FMT_RGBA, videoWidth, videoHeight, 1);
// R9 Data Format Conversion Context
struct SwsContext *data_convert_context = sws_getContext(
videoWidth, videoHeight, video_codec_context->pix_fmt,
videoWidth, videoHeight, AV_PIX_FMT_RGBA,
SWS_BICUBIC, NULL, NULL, NULL);
// Start reading frames

这里 ANative 相关代码都是 Android NDK 中的 api,可参考文档理解;这里主要看一下这里的 AVPacket 和 AVFrame 的使用

AVPacket 在上一篇文章中有过介绍,它在 FFMpeg 中用来表示未解码时的数据;而 AVFrame 则表示了解码后的帧数据。

但这里有一个小问题是我们解码后的图像格式可能是和我们的 surface 的渲染格式不同,这里我们的 surface 渲染格式是RGBA8888,而视频的图像格式不一定为这个格式。为了实现转换,我们就需要SwsContext对象来帮助我们实现图像格式的转换。

看完上面后,终于来到了我们的解码 runloop

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
while (av_read_frame(format_context, packet) >= 0) {
// Matching Video Stream
if (packet->stream_index == video_stream_index) {
// Decode video
result = avcodec_send_packet(video_codec_context, packet);
if (result < 0 && result != AVERROR(EAGAIN) && result != AVERROR_EOF) {
LOGE("Player Error : codec step 1 fail");
return;
}
result = avcodec_receive_frame(video_codec_context, frame);
if (result < 0 && result != AVERROR_EOF) {
LOGE("Player Error : codec step 2 fail");
return;
}
LOGI("Frame %c (%d, size=%d) pts %d dts %d key_frame %d [codec_picture_number %d, display_picture_number %d]",
av_get_picture_type_char(frame->pict_type), video_codec_context->frame_number,
frame->pkt_size,
frame->pts,
frame->pkt_dts,
frame->key_frame, frame->coded_picture_number, frame->display_picture_number);
// Data Format Conversion
result = sws_scale(
data_convert_context,
frame->data, frame->linesize,
0, videoHeight,
rgba_frame->data, rgba_frame->linesize);
// play
result = ANativeWindow_lock(native_window, &window_buffer, NULL);
if (result < 0) {
LOGE("Player Error : Can not lock native window");
} else {
// Draw the image onto the interface
// Note: The pixel lengths of rgba_frame row and window_buffer row may not be the same here.
// Need to convert well or maybe screen
uint8_t *bits = (uint8_t *) window_buffer.bits;
for (int h = 0; h < videoHeight; h++) {
memcpy(bits + h * window_buffer.stride * 4,
out_buffer + h * rgba_frame->linesize[0],
rgba_frame->linesize[0]);
}
ANativeWindow_unlockAndPost(native_window);
}
}
// Release package references
av_packet_unref(packet);
}

整个解码循环也是大致分为如下几个步骤

  1. 通过 AVPacket 读取一块数据
  2. 将 AVPacket 送给解码器使用
  3. 通过 AVFrame 得到 2 中的解码后数据
  4. 对 AVFrame 的图像格式进行转换
  5. 将 4 中的结果上屏

其他的注释在代码中有说明

最后在 run-loop 外完成资源回收

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Release R9
sws_freeContext(data_convert_context);
// Release R8
av_free(out_buffer);
// Release R7
av_frame_free(&rgba_frame);
// Release R6
av_frame_free(&frame);
// Release R5
av_packet_free(&packet);
// Release R4
ANativeWindow_release(native_window);
// Close R3
avcodec_close(video_codec_context);
// Release R2
avformat_close_input(&format_context);
// Release R1
env->ReleaseStringUTFChars(path_, path);

此时,我们就完成了整个视频的播放,不过这个播放器显然是处在不可用的状态,主要有下面两个问题

  1. 没有声音
  2. 视频的播放是按照解码器的解码速度播放的;只要解码器足够快,视频播放就有多快,这个显然是不符合播放器播放视频的预期的

问题 2 本质就是我们常说的音视频同步问题;我们后面会简单介绍音频的解码播放后,通过引入 pts, dts 等概念后,尝试解决问题 2

FFMpeg-Android开发-解析视频格式

在正式使用FFMpeg完成视频的播放之前,我想先写一篇文章,简单介绍一下如何使用FFMpeg去获取一个视频的基本信息。通过这篇文章,来简单介绍一下FFmpeg中的相关概念,以及视频的一些基本概念。

视频基本概念

当我们提及视频格式的时候,实际上对应的是视频的container(又或者称为format)这个概念,这个概念往往对应mpeg4,mkv, webm等。一个container可以是由多个编码器压缩后的媒体的集合,例如,一个mp4,可以包含视频,音频,字幕等不同媒体

编码器codec,则是按照某种编解码标准下的具体实现,这一层对应的概念往往是av1, h264, vp9等

因此,一个视频文件,从读取IO到播放大致可以分为以下几步:

  1. 解析container
  2. 根据container,获得我们关心的媒体数据(例如播放视频,那我们只关心视频媒体)
  3. 根据媒体信息获得对应的解码器
  4. 将对应的数据送给解码器
  5. 解码器解码,输出帧
  6. 将帧渲染到目标区域

FFmpeg中对上述概念的定义

那么在FFmpeg中,是如何定义上述概念的呢?

对于container,FFMpeg使用AVFormatContext
对于具体的媒体数据,通过遍历AVFormatContext->streams,我们能通过一个AVStream对象表示

AVStream中的压缩分片用AVPacket表示,通过解码器解码后我们就能得到帧数据,用AVFrame表示

这篇文章就先简单介绍下1,2,3步的实现

使用FFMpeg解析container,获取基本信息

代码如下

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

AVFormatContext *avFormatContext = nullptr;
LOGI("video_config_create, open file uri: %s", uri);
if (avformat_open_input(&avFormatContext, uri, nullptr, nullptr)) { // open IO, 如果成功, avFormatContext将有值
return nullptr;
}


if (avformat_find_stream_info(avFormatContext, nullptr) < 0) {
avformat_free_context(avFormatContext);
return nullptr;
};

for (int pos = 0; pos < avFormatContext->nb_streams; pos++) {
// Getting the name of a codec of the very first video stream
AVCodecParameters *parameters = avFormatContext->streams[pos]->codecpar;
if (parameters->codec_type == AVMEDIA_TYPE_VIDEO) {
videoConfig->parameters = parameters;
videoConfig->avVideoCodec = avcodec_find_decoder(parameters->codec_id);
videoConfig->videoStreamIndex = pos;
break;
}
}

代码的基本逻辑,和上述介绍的步骤,是一致的;其中,视频的文件格式信息,显然就在AVFormatContext中,视频的宽高信息,就在AVCodecParameters中,这个对象顾名思义,是跟随codec的;而解码器的相关信息,就在avcodec_find_decoder的返回值中

本文对应的源代码请参考https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v1.0

FFMpeg Android开发101-编译和引入

本系列会尝试将FFMpeg引入到android项目中,并借助FFMpeg完成一些音视频的简单饮用;FFMpeg作为一个成熟的音视频编解码工具被大量项目使用,但将FFMpeg引入到Android开发的文档并不多,国内有一部分但大量已经过时,这个系列会重新尝试带领大家完成整个过程。

本文对应的源代码请参考https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v1.0

1
2
3
4
5
6
7
8
9
git clone https://github.com/TedaLIEz/MyFFMpegAndroid/tree/v1.0

cd MyFFMpegAndroid
git submodule update

cd ffmpeg-android-maker
export ANDROID_SDK_HOME=${YOUR_SDK_HOME}
export ANDROID_NDK_HOME=${YOUR_NDK_HOME}
./ffmpeg-android-maker.sh

之后用Android Studio打开项目,即可运行

前言

总的来说,将FFMpeg引入到Android项目开发分为下面几个步骤:

  1. 使用Android NDK编译FFMpeg项目
  2. 在自己的项目部署FFMpeg
  3. 开始FFMpeg开发,验证效果

使用Android NDK编译FFMpeg

TL;DR 参考https://github.com/Javernaut/ffmpeg-android-maker

首先需要简单了解些FFMpeg编译产物的一些职责:

libavformat: 处理文件container, stream
libavcodec: 编解码
libswscale: 图像处理
libavutil: util库

还有一些其他的库,可以自行参考FFmpeg文档

考虑到我们是编译一个Android的FFMpeg库,本质上这就是一个交叉编译,因此我简单介绍一下FFmpeg编译到Android上的一些配置

编译配置

由于Android有着ARM, x86的32,64位处理器架构,因此我们需要编译4种二进制文件;这时需要一个交叉编译器(cross-compiler)来帮我们处理问题。

第二,编译过程不只是compile,还包括链接(linker)等其他工具, 我们统称为binutils

第三,我们需要Android系统自己的一些库和头文件,这些文件的存储位置我们成为sysroot

因此,整个编译需要的工具链包括cross-compiler, binutils, sysroot

那么我们怎么获得这些工具呢?答案显然就是在Android NDK中,如果之前你有查阅过Android-NDK的目录,那么你就会发现NDK的结构其实已经说明了这三个工具的划分

这里我们看一下configure的相关参数

1
2
3
4
5
6
7
8
9
10
11
12
./configure \
--prefix=${BUILD_DIR}/${ABI} \
--enable-cross-compile \
--target-os=android \
--arch=${TARGET_TRIPLE_MACHINE_BINUTILS} \
--sysroot=${SYSROOT} \
--cross-prefix=${CROSS_PREFIX} \
--cc=${CC} \
--extra-cflags="-O3 -fPIC" \
--enable-shared \
--disable-static \
${EXTRA_BUILD_CONFIGURATION_FLAGS} \

prefix指明了产物的路径

target-os=android: 指明我们的编译操作系统是Android

--arch=${TARGET_TRIPLE_MACHINE_BINUTILS}: 指明arm, aarch64, i686 and x86_64

--sysroot=${SYSROOT}: 指明sysroot的路径,这个路径一定是在Android NDK的路径下的一个子目录

--cross-prefix=${CROSS_PREFIX}: 指明binutils的工具名前缀,这个名字会追加到ld前当作linker使用,例如

1
2
3
4
armeabi-v7a: $TOOLCHAIN_PATH/bin/arm-linux-androideabi-
arm64-v8a: $TOOLCHAIN_PATH/bin/aarch64-linux-android-
x86: $TOOLCHAIN_PATH/bin/i686-linux-android-
x86_64: $TOOLCHAIN_PATH/bin/x86_64-linux-android-

--cc=${CC}: 指明编译器, 例如

1
2
3
4
armeabi-v7a: $TOOLCHAIN_PATH/bin/armv7a-linux-androideabi16-clang
arm64-v8a: $TOOLCHAIN_PATH/bin/aarch64-linux-android21-clang
x86: $TOOLCHAIN_PATH/bin/i686-linux-android16-clang
x86_64: $TOOLCHAIN_PATH/bin/x86_64-linux-android21-clang

--enable-shard--disable-static表明我们是编译一个动态库

--extra-cflags=”-O3 -fPIC”表明了其他的C flag, -O3表明编译器优化级别, -fPIC是编译Android上的动态库必须的参数

FFMpeg自身的相关配置

除去上面编译工具链需要的参数外,我们还可以定制化我们的FFMpeg编译,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--disable-runtime-cpudetect \
--disable-programs \
--disable-muxers \
--disable-encoders \
--disable-avdevice \
--disable-postproc \
--disable-swresample \
--disable-avfilter \
--disable-doc \
--disable-debug \
--disable-pthreads \
--disable-network \
--disable-bsfs \
--disable-decoders \
${DECODERS_TO_ENABLE}

这里--disable-xxx表示不需要FFMpeg的具体模块,这个可以根据自身app的开发来定制

上述工作全部完成后,就可以开始make && make install了

在Android项目中引入FFmpeg

编译完成后,你应该能获得如下的编译结果

1
2
3
4
5
6
7
8
9
10
11
12

.
├── include
│   ├── arm64-v8a
│   ├── armeabi-v7a
│   ├── x86
│   └── x86_64
└── lib
├── arm64-v8a
├── armeabi-v7a
├── x86
└── x86_64

下一步就是引入到Android项目中了,首先在你的app/build.gradle中作如下修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
android {
...
defaultConfig {
...
ndk {
abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
}
}
sourceSets {
main {
// let gradle pack the shared library into the apk
jniLibs.srcDirs = ['../ffmpeg-android-maker/output/lib']
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
}

分别解释一下:

abiFilters: 指定了app支持的架构

jniLibs.srcDirs: 指定了app需要的动态库路径,android编译时会自动将这里指定的动态库打包进apk

externalNativeBuild这里指定了我们的CMakeLists.txt路径

下一步就是配置CMakeLists

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
cmake_minimum_required(VERSION 3.4.1)  # 指定Cmake最低版本

set(ffmpeg_dir ${CMAKE_SOURCE_DIR}/../ffmpeg-android-maker/output) # 设置ffmpeg_dir变量
include_directories(${ffmpeg_dir}/include/${ANDROID_ABI}) # 设置需要include的头文件路径,注意这里的ANDROID_ABI代表了在gradle中指定的abiFilters的每一个变量
set(ffmpeg_libs ${ffmpeg_dir}/lib/${ANDROID_ABI}) # 设置ffmpeg_libs变量,指明shared library路径



add_library(avutil SHARED IMPORTED) # 声明avutil库
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${ffmpeg_libs}/libavutil.so) # 指定avutil库shared library路径

add_library(avformat SHARED IMPORTED) # 类似上面的声明
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${ffmpeg_libs}/libavformat.so)

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

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

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

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

find_library(log-lib log) # 使用Android 的native log库,命名为log-lib
find_library(jnigraphics-lib jnigraphics) # 同上,使用jnigraphics

add_library(test_ffmpeg
SHARED
src/main/cpp/video_config.cpp
src/main/cpp/video_config_jni.cpp
src/main/cpp/utils.cpp
src/main/cpp/main.cpp
src/main/cpp/player.cpp) # 添加我们自己项目中的代码,并命名为test_ffmpeg库

target_link_libraries(
test_ffmpeg
${log-lib}
${jnigraphics-lib}
android
avformat
avcodec
swscale
avutil
swresample
avfilter) # 通知linker将上述所有library链接

具体含义已经在注释中

全部配置完成后,可以尝试进行编译;剩下的问题就是JNI相关的知识和Android自身的相关开发知识了,本文就不再赘述。需要注意的一个小点就是在Java层System.loadLibrary时需要注意加载顺序

1
2
3
4
5
init {
listOf("avutil", "avcodec", "avformat", "swscale", "test_ffmpeg").forEach {
System.loadLibrary(it)
}
}

之后运行app,如果没有出现crash,就基本证明我们的引入是ok的了。Have Fun!

Jetpack Compose简介与思考

Jetpack Compose可以认为是Android对UI代码架构的演进,核心目的是为了让安卓自身的UI代码能够跟随现代UI开发的步伐

现代UI开发的特性

现代UI开发的一个重要特点在于其代码为声明式的代码结构,从数学关系上来看,UI的代码构成可以描述为对某一时刻状态的一个函数,即UI=F(n),其中n表示了当前的交互状态。参考MVVM的设计来说,这个状态可以由ViewModel来管理

安卓的UI开发现状

安卓的UI开发思路和代码结构已经明显老化,主要体现在下面一些方面:

  1. 仍旧有xml这样的配置文件,即使出现了ViewBinding也无济于事
  2. 构建UI时有大量的命令式代码,这种代码散落在Activity/Fragment中,不利于维护

命令式编程与声明式编程的争论

命令式编程风格的问题在于程序非常依赖过程,换言之,如果代码编写的顺序出错,那么程序也会出错;而声明式编程则是只关心结果,编写代码时,只需要把开发者最终想要的结果直接写入代码,如果需要变化,则直接修改你的声明代码。并且声明式风格的UI代码在状态改变的任意时刻都是同一套代码构建UI, 这有助于提升代码质量

声明式风格编码特点在于:

Describe your UI right now. For any value of now.

这种风格是一种状态无关代码(status independent)

Jetpack Compose

Jetpack Compose的目的就在于改变现有的UI开发思路,完全向声明式函数演进,同时提供了一些便捷功能,例如一个常规的UI声明如下:

1
2
3
4
5
6
7
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<TextView>(R.id.tv).text = "Android"
}
}

对于复杂的UI界面,这种UI代码的编码方式是可以极其零散的, 可能一部分写在xml里面,一部分又写在代码里,同时代码里的编码可以任意地乱序(命令式代码的弊端)。而Compose出现后,我们的编码方式就可以变化为

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

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp {
Greeting(name = "Android")
}
}
}
}


@Composable
fun MyApp(content: @Composable() () -> Unit) {
MaterialTheme {
content()
}
}

@Composable
fun Greeting(name: String) {
Surface(color = Color.Yellow) {
Text(text = "Hello $name!", modifier = Modifier.padding(24.dp))
}
}

乍一看这种编码风格非常像Flutter,React,Vue的UI代码风格,本质上这些前端框架的UI设计都遵循了声明式函数风格,通过将编码方式转化为声明式函数风格后,我们间接地获得了以下收益

  1. 各个函数独立,复用性提升
  2. UI代码紧凑

复用性提升的直接效果之一就是我们可以在IDE里面直接preview UI效果,这个也是Android Studio 4.2后内置的能力,只需要给你的@Composable函数加上一个@Preview标签并进行编译,这个函数自己的UI效果就能直接在IDE上展示

UI代码紧凑则明确了任何对UI的修改都有唯一的入口,例如在上面的例子中,Activity中一定是setContent

Jetpack Compose的状态管理

声明式风格的UI都面临一个问题就是如何处理状态改变,Jetpack Compose和其他常见的声明式UI框架一致,采用了state这个概念,通过声明一个state对象,我们根据这个state对象构造自己的UI和其子View,当state改变时,动态的改变这个UI的声明,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable
fun MyScreenContent() {
val counterState = state { 0 }
Counter(
count = counterState.value,
updateCount = { newCount ->
counterState.value = newCount
}
)
}


@Composable
fun Counter(count: Int, updateCount: (Int) -> Unit) {
Button(onClick = { updateCount(count+1) }) {
Text("I've been clicked $count times")
}
}

这个例子中,我们就能发现MyScreenContent中内含了一个counterState对象,它通过counterState构建了一个Counter,而Counter也是counterState的一个函数,当counterState变化时,Counter也会重新构建,UI因此也发生变化

Jetpack Compose中Lifecycle

Android开发非常强调Lifecycle-aware,compose也是如此。为此Jetpack Compose提出了Efforts这个概念,在这个概念中提供了onCommit, onPreCommit, onActive, onDispose一系列函数,用来监听生命周期变化

感想和思考

似乎在UI开发上,声明式编程已经成为了主流的方向。随着SwiftUI,Flutter,Jetpack Compose的出现,越来越多的新兴UI开发框架抛弃了现有的MVC,MVP模式,而走向声明式UI方向,随着声明式UI被引入移动端开发,新兴的MVI模式也进入了大众视野,并逐渐被人们接受

MMKV源码简析---跨进程

MMKV 一个重要特性就是增加了 android 侧对跨进程读写的支持,我们单独用一篇文章来分析一下 MMKV 对跨进程存储的实现方式
典型的初始化调用如下:

1
2
3
4
MMKV mmkv = MMKV.mmkvWithID(MMKV_ID, MMKV.MULTI_PROCESS_MODE, CryptKey);

mmkv.encode(...)
mmkv.decode(...)

MMKV 会把每个文件都通过 mmap 到进程的访问空间,因此每个进程本身就可以直接访问存储,但这里没有解决跨进程的并发访问问题,解决并发需要别的手段,我们看看 MMKV.MULTI_PROCESS_MODE 造成了什么影响

MMKV.MULTI_PROCESS_MODE

参考初始化流程的代码和MMKV.MULTI_PROCESS_MODE=2, 来到 MMKV 构造函数, 可以发现此时 MMKV::m_isInterProcess=true, 这个对代码逻辑有什么影响呢?

  1. checkLoadData 的后半段代码会执行
  2. m_sharedProcessLock, m_exclusiveProcessLock 全部 enable

先来看看 checkLoadData 的后半段代码是啥

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
if (!m_isInterProcess) {
return;
}

if (!m_metaFile->isFileValid()) {
return;
}
// TODO: atomic lock m_metaFile?
MMKVMetaInfo metaInfo;
metaInfo.read(m_metaFile->getMemory()); // 文件m_metaFile被mmap,每个进程都能读到, 但是m_metaInfo是内存对象,没有被进程共享,因此有可能存储的元数据和文件中的元数据不一致
if (m_metaInfo->m_sequence != metaInfo.m_sequence) {
MMKVInfo("[%s] oldSeq %u, newSeq %u", m_mmapID.c_str(), m_metaInfo->m_sequence, metaInfo.m_sequence);
SCOPED_LOCK(m_sharedProcessLock);

clearMemoryCache();
loadFromFile();
notifyContentChanged();
} else if (m_metaInfo->m_crcDigest != metaInfo.m_crcDigest) {
MMKVDebug("[%s] oldCrc %u, newCrc %u, new actualSize %u", m_mmapID.c_str(), m_metaInfo->m_crcDigest,
metaInfo.m_crcDigest, metaInfo.m_actualSize);
SCOPED_LOCK(m_sharedProcessLock);

size_t fileSize = m_file->getActualFileSize();
if (m_file->getFileSize() != fileSize) {
MMKVInfo("file size has changed [%s] from %zu to %zu", m_mmapID.c_str(), m_file->getFileSize(), fileSize);
clearMemoryCache();
loadFromFile();
} else {
partialLoadFromFile();
}
notifyContentChanged();
}

相比较于单进程,这里增加了对内存中的 meta 和文件的 meta 的 seq 和 crc 校验比较,如果不相等,则需要走文件重新加载逻辑,这一块,和https://github.com/Tencent/MMKV/wiki/android_ipc#%E7%8A%B6%E6%80%81%E5%90%8C%E6%AD%A5描述是一致的

m_sharedProcessLock, m_exclusiveProcessLock 全部 enable 应该就是使锁生效,那么分析一下这个锁是如何实现跨进程锁的

InterProcessLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class InterProcessLock {
FileLock *m_fileLock;
LockType m_lockType;

public:
InterProcessLock(FileLock *fileLock, LockType lockType)
: m_fileLock(fileLock), m_lockType(lockType), m_enable(true) {
MMKV_ASSERT(m_fileLock);
}

bool m_enable;

void lock() {
if (m_enable) {
m_fileLock->lock(m_lockType);
}
}
// ...
}

可以看到,MMKV 的跨进程锁是基于文件锁实现的, InterProcessLock#lock基于FileLock.lock实现, 一路转发来到FileLock::platformLock()

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

bool FileLock::doLock(LockType lockType, bool wait) {
if (!isFileLockValid()) {
return false;
}
bool unLockFirstIfNeeded = false;

if (lockType == SharedLockType) {
// don't want shared-lock to break any existing locks
if (m_sharedLockCount > 0 || m_exclusiveLockCount > 0) {
m_sharedLockCount++;
return true;
}
} else {
// don't want exclusive-lock to break existing exclusive-locks
if (m_exclusiveLockCount > 0) {
m_exclusiveLockCount++;
return true;
}
// prevent deadlock
if (m_sharedLockCount > 0) {
unLockFirstIfNeeded = true;
}
}

auto ret = platformLock(lockType, wait, unLockFirstIfNeeded);
if (ret) {
if (lockType == SharedLockType) {
m_sharedLockCount++;
} else {
m_exclusiveLockCount++;
}
}
return ret;
}

bool FileLock::platformLock(LockType lockType, bool wait, bool unLockFirstIfNeeded) {
# ifdef MMKV_ANDROID
if (m_isAshmem) {
return ashmemLock(lockType, wait, unLockFirstIfNeeded);
}
# endif
auto realLockType = LockType2FlockType(lockType);
auto cmd = wait ? realLockType : (realLockType | LOCK_NB);
if (unLockFirstIfNeeded) {
// try lock
auto ret = flock(m_fd, realLockType | LOCK_NB);
if (ret == 0) {
return true;
}
// let's be gentleman: unlock my shared-lock to prevent deadlock
ret = flock(m_fd, LOCK_UN);
if (ret != 0) {
MMKVError("fail to try unlock first fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
}
}

auto ret = flock(m_fd, cmd);
if (ret != 0) {
MMKVError("fail to lock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
// try recover my shared-lock
if (unLockFirstIfNeeded) {
ret = flock(m_fd, LockType2FlockType(SharedLockType));
if (ret != 0) {
// let's hope this never happen
MMKVError("fail to recover shared-lock fd=%d, ret=%d, error:%s", m_fd, ret, strerror(errno));
}
}
return false;
} else {
return true;
}
}

可以看到 MMKV 在文件锁 flock 的基础上,增加了一套计数器机制(代码中的 m_sharedLockCount 和 m_exclusiveLockCount),来确保

  1. 锁的重入特性
  2. 锁的共享和互斥性转换特性

关于文件锁的细节,可以参考https://gavv.github.io/articles/file-locks

MMKV源码简析---读写

我们还是从 testcase 出发,一点点看 MMKV 如何实现读写的

MMKV 写

1
2
3
val mmkv = MMKV.mmkvWithID("testKotlin")
mmkv.encode("bool", true)
mmkv.encode("int", Integer.MIN_VALUE)

MMKV#encode 支持所有基本类型, 这里我们挑一个简单的 bool 类型分析一下,根据调用链MMKV#encode->MMKV#encodeBool来到 native-bridge.cpp 中的 JNI 接口,找到其实现MMKV::set(bool value, MMKVKey_t key)

1
2
3
4
5
6
7
8
9
10
11
12
// MMKVKey_t = const string&
bool MMKV::set(bool value, MMKVKey_t key) {
if (isKeyEmpty(key)) {
return false;
}
size_t size = pbBoolSize();
MMBuffer data(size);
CodedOutputData output(data.getPtr(), size);
output.writeBool(value);

return setDataForKey(std::move(data), key);
}

这里根据 pb 协议的规范,分配了一块和 pb 协议中商定的类型字段长度相同的 buffer MMBuffer, 将这个 buffer 利用 CodedOutputData 这个工具类写入一个 bool,之后调用setDataForKey来记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool MMKV::setDataForKey(MMBuffer &&data, MMKVKey_t key) {
if (data.length() == 0 || isKeyEmpty(key)) {
return false;
}
SCOPED_LOCK(m_lock);
SCOPED_LOCK(m_exclusiveProcessLock);
checkLoadData(); // #1

auto ret = appendDataWithKey(data, key); // #2
if (ret) {
m_dic[key] = std::move(data);
m_hasFullWriteback = false;
}
return ret;
}

checkLoadData()

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
void MMKV::checkLoadData() {
if (m_needLoadFromFile) { // m_needLoadFromFile在MMKV构造和clearMemoryCache调用后才会是true
SCOPED_LOCK(m_sharedProcessLock);

m_needLoadFromFile = false;
loadFromFile(); // 初始化过程中也调用了这个函数,相当于加载存储中的内容
return;
}
if (!m_isInterProcess) {
// 不是跨进程模式的MMKV,直接返回
return;
}

if (!m_metaFile->isFileValid()) {
return;
}
// TODO: atomic lock m_metaFile?
MMKVMetaInfo metaInfo;
metaInfo.read(m_metaFile->getMemory());
if (m_metaInfo->m_sequence != metaInfo.m_sequence) { // 内存中的seq和文件中的seq不一致,文件有新的改动,清理缓存,重新读文件,通知内容更改
MMKVInfo("[%s] oldSeq %u, newSeq %u", m_mmapID.c_str(), m_metaInfo->m_sequence, metaInfo.m_sequence);
SCOPED_LOCK(m_sharedProcessLock);

clearMemoryCache();
loadFromFile();
notifyContentChanged();
} else if (m_metaInfo->m_crcDigest != metaInfo.m_crcDigest) { // 内存中的crc校验值和文件中crc校验值不一致
MMKVDebug("[%s] oldCrc %u, newCrc %u, new actualSize %u", m_mmapID.c_str(), m_metaInfo->m_crcDigest,
metaInfo.m_crcDigest, metaInfo.m_actualSize);
SCOPED_LOCK(m_sharedProcessLock);

size_t fileSize = m_file->getActualFileSize();
if (m_file->getFileSize() != fileSize) {
// 有新内容写入,清理memory,重新读文件,通知内容更改
MMKVInfo("file size has changed [%s] from %zu to %zu", m_mmapID.c_str(), m_file->getFileSize(), fileSize);
clearMemoryCache();
loadFromFile();
} else {
// 现有内容有变化,存量加载
partialLoadFromFile();
}
notifyContentChanged(); // 通知Java层通过setWantsContentChangeNotify注册的回调
}
}

逻辑比较清晰,主要看看 partialLoadFromFile 这个函数

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
// read from last m_position
void MMKV::partialLoadFromFile() {
m_metaInfo->read(m_metaFile->getMemory());

size_t oldActualSize = m_actualSize;
m_actualSize = readActualSize();
auto fileSize = m_file->getFileSize();
MMKVDebug("loading [%s] with file size %zu, oldActualSize %zu, newActualSize %zu", m_mmapID.c_str(), fileSize,
oldActualSize, m_actualSize);

if (m_actualSize > 0) {
if (m_actualSize < fileSize && m_actualSize + Fixed32Size <= fileSize) {
if (m_actualSize > oldActualSize) {
// 有新内容
size_t bufferSize = m_actualSize - oldActualSize;
auto ptr = (uint8_t *) m_file->getMemory();
MMBuffer inputBuffer(ptr + Fixed32Size + oldActualSize, bufferSize, MMBufferNoCopy);
// incremental update crc digest
m_crcDigest =
(uint32_t) CRC32(m_crcDigest, (const uint8_t *) inputBuffer.getPtr(), inputBuffer.length());
if (m_crcDigest == m_metaInfo->m_crcDigest) {
if (m_crypter) {
decryptBuffer(*m_crypter, inputBuffer);
}
MiniPBCoder::greedyDecodeMap(m_dic, inputBuffer, bufferSize); // 解析buffer内容到m_dic, buffer的结构是线性的string-MMBuffer KV结构
m_output->seek(bufferSize);
m_hasFullWriteback = false;

MMKVDebug("partial loaded [%s] with %zu values", m_mmapID.c_str(), m_dic.size());
return;
} else {
MMKVError("m_crcDigest[%u] != m_metaInfo->m_crcDigest[%u]", m_crcDigest, m_metaInfo->m_crcDigest);
}
}
}
}
// something is wrong, do a full load
clearMemoryCache();
loadFromFile();
}

appendDataWithKey

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
bool MMKV::appendDataWithKey(const MMBuffer &data, MMKVKey_t key) {
size_t keyLength = key.length();
// size needed to encode the key
size_t size = keyLength + pbRawVarint32Size((int32_t) keyLength);
// size needed to encode the value
size += data.length() + pbRawVarint32Size((int32_t) data.length());

SCOPED_LOCK(m_exclusiveProcessLock);

bool hasEnoughSize = ensureMemorySize(size);
if (!hasEnoughSize || !isFileValid()) {
return false;
}
m_output->writeString(key);
m_output->writeData(data); // note: write size of data

auto ptr = (uint8_t *) m_file->getMemory() + Fixed32Size + m_actualSize;
if (m_crypter) {
m_crypter->encrypt(ptr, ptr, size);
}
m_actualSize += size;
updateCRCDigest(ptr, size); // 更新crc校验和seq

return true;
}

MMKV 读

同样地,通过 JNI 找到MMKV::getBool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bool MMKV::getBool(MMKVKey_t key, bool defaultValue) {
if (isKeyEmpty(key)) {
return defaultValue;
}
SCOPED_LOCK(m_lock);
auto &data = getDataForKey(key); // #1
if (data.length() > 0) {
try {
CodedInputData input(data.getPtr(), data.length());
return input.readBool();
} catch (std::exception &exception) {
MMKVError("%s", exception.what());
}
}
return defaultValue;
}

实现关键在 getDataForKey

1
2
3
4
5
6
7
8
9
const MMBuffer &MMKV::getDataForKey(MMKVKey_t key) {
checkLoadData();
auto itr = m_dic.find(key);
if (itr != m_dic.end()) {
return itr->second;
}
static MMBuffer nan;
return nan;
}

checkLoadData 函数通过调用 loadFromFile 会确保所有的文件数据被读进 m_dic 中,这里直接取就可以了

小结

MMKV 的读写逻辑比较简单,主要的实现还是依赖了 PB 方式的数据序列化和反序列化,根据代码逻辑,我们可以尝试还原出 MMKV 内部的存储结构如下:

buffer
1
2
3
4
5
6
7
8
9
message KV {
string key = 1;
buffer value = 2;
}

message MMKV {
int32 size = 1; // 文件大小,用于校验新数据写入
repeated KV kv = 2;
}

同时我们可以通过MiniPBCoder::decodeOneMap的代码实现可以发现,MMKV 对于新旧数据问题,采用的方式不是写覆盖,而是直接追加,同时读时以最后一次写为最新值。这种方式显然是会带来大量的 key 字段冗余,因此必然存在一套完整的空间优化

MMKV 空间优化

核心实现在MMKV::ensureMemorySize

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
bool MMKV::ensureMemorySize(size_t newSize) {
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}

// make some room for placeholder
constexpr size_t ItemSizeHolderSize = 4;
if (m_dic.empty()) {
newSize += ItemSizeHolderSize;
}
if (newSize >= m_output->spaceLeft() || m_dic.empty()) {
// 空间不够,进行一次全量写入,这次全量写入会把过长的冗余字段全部覆盖写
// try a full rewrite to make space
auto fileSize = m_file->getFileSize();
MMBuffer data = MiniPBCoder::encodeDataWithObject(m_dic);
size_t lenNeeded = data.length() + Fixed32Size + newSize;
size_t avgItemSize = lenNeeded / std::max<size_t>(1, m_dic.size());
size_t futureUsage = avgItemSize * std::max<size_t>(8, (m_dic.size() + 1) / 2);
// 1. no space for a full rewrite, double it
// 2. or space is not large enough for future usage, double it to avoid frequently full rewrite
if (lenNeeded >= fileSize || (lenNeeded + futureUsage) >= fileSize) {
size_t oldSize = fileSize;
do {
fileSize *= 2; // 2倍的扩充策略
} while (lenNeeded + futureUsage >= fileSize);
MMKVInfo("extending [%s] file size from %zu to %zu, incoming size:%zu, future usage:%zu", m_mmapID.c_str(),
oldSize, fileSize, newSize, futureUsage);

// if we can't extend size, rollback to old state
if (!m_file->truncate(fileSize)) {
return false;
}

// check if we fail to make more space
if (!isFileValid()) {
MMKVWarning("[%s] file not valid", m_mmapID.c_str());
return false;
}
}
return doFullWriteBack(std::move(data)); // 全量写入
}
return true;
}

整个读写过程分析到这里,后面将分析一下 MMKV 对于跨进程存储的实现