KaelMa

Programmer & Dotaer


  • Home

  • Archives

  • About

Lottie库的引入评估

Posted on 2017-05-20 |

一、包体大小

截至到2017年5月份,Lottie最新版本支持API版本是14,引入后,增加大小大约为170KB,在包体大小上不是问题。

二、性能分析

2.1 原理

从原理上来说,动画其实只有2种:

  • 逐帧动画:

    逐帧动画就是动画的每一帧都独立地保存在媒体内,连续播放这些帧即形成了连续动画,而用户需要保存每一帧的所有像素数据, 可以认为逐帧动画的每一帧图像都是在其他地方预先渲染好的(PS,AE等)。

    比如一个30FPS(Frame Per Second),像素大小为(480*240)的逐帧动画,用户需要保存30张图片的像素数据,并在1秒内将这30张图片顺序播放。比如Gif格式的动画,只是将这些像素打包在一起以减少了部分空间。

    从广义上来说,视频都属于这个范围。由于目前项目中gif动画使用的最广泛,这里以Gif为代表。

  • 关键帧动画:

    关键帧动画就是不保存动画的每一帧,而通过前后两个关键帧来自动计算中间的过度画面。计算机可以根据前后两个关键帧的数据自动插值补全中间的动画。

    比如以位移动画来说,一个图像从a点移动到b点,在a点会有一个关键帧,在b点会有另一个关键帧,至于关键帧之间的中间帧,计算机会自动插值计算。还可以通过更改插值器等操作来修改这一过程。Lottie属于关键帧动画。

2.2 对比

从实现上来说,我们在Android开发中使用动画主要有下面几种选择:

  • Gif与序列帧
  • 程序实现动画(使用属性动画接口等)
  • Lottie

而Lottie是基于属性动画实现的,其实现步骤大致如下:

Lottie实际上做的工作是自动根据UE导出的关键帧数据来绘制动画,因此,就性能上来说,Lottie多了一个反序列化json文件到内存中的开销,性能必定是低于直接用程序绘制的。但也正因为如此,这个大大提高了程序员的实现动画的效率,再也不用根据UE给的动画文件手动调整动画参数,这些都交给Lottie库自动完成了。

  • 和Gif以及序列帧的对比:

Gif动画或者序列帧的性能瓶颈主要在于每一帧替换纹理引起的state validation阶段,即每一帧替换纹理的CPU开销(非GPU)是比较大的。当然还有一种Gif或者序列帧的实现方式将每一帧的纹理合并在一张纹理上加载进内存,通过每一帧改变纹理坐标进行动画播放,这种方式性能上是比较好的,但是由于要一次加载所有纹理,对内存的消耗很大,只能适用于帧数不多,体积比较小的Gif或者序列帧文件。

而Lottie的性能开销主要在于根据关键帧数值实时的计算出每一帧数据进行绘制,如果动画越复杂,实时计算出每一帧的开销就越大。因此,在CPU性能上,并不能断言Lottie和Gif谁更优,这取决于实际使用的动画场景。但是由于Lottie不需要加载每一帧纹理,因此,在内存上肯定是由于GIf的。

性能对比 CPU开销 内存开销 建议使用场景
Gif 需要具体分析 高 非常复杂的动画
Lottie 需要具体分析 低 相对简单的动画

实际上,Lottie并不是代替Gif的一种方案,在动画非常复杂的情况下,Lottie并不能实现。Lottie实际上做的是代替大部分的程序绘制动画,避免UE实现一次,RD再实现一次的重复劳动。

3、实际使用场景分析

Lottie由于关键帧的数据是通过UE导出的json文件反序列化而来(具体源码分析请参见前一篇文章)。因此,对于需要在动画中间动态改变一些信息的需求目前没法实现,比如需要在动画中间某段时间动态改变动画的颜色或者速度等,Lottie目前是不能直接使用的,需要对Lottie进行开发。

另外在实际使用Lottie过程中还有一点比较局限,就是如果想对当前View中的某些部件进行动画,比如想对一个Button进行放大缩小动画,Lottie目前还不支持,需要自己实现。

最后一点就是Lottie也一直在更新,现在对于AE中的某些效果还没有实现,即UE如果使用了Lottie不支持的一些属性实现动画,那么导出的json文件Lottie是无法正确显示的。但是这一点风险可控,因为这个在debug阶段一定可以发现。

总结下来,Lottie目前最适合的使用场景是那些相对比较独立的动画,即动画过程中不会动态调整,比如Loading动画、引导动画这些。在动画不是非常复杂的情况下,可以代替大部分的Gif动画。Lottie这个库的主要价值在于提升实现动画的开发效率,避免了RD与UE重复实现同一个动画的工作,并且可以完美的实现UE所设计的动画效果。

Android动画原理与Lottie库源码分析

Posted on 2017-05-20 |

基本动画原理

1.动画类型

严格意义上来说,动画其实只有2种:

  • 逐帧动画:

    逐帧动画就是动画的每一帧都独立地保存在媒体内,连续播放这些帧即形成了连续动画,而用户需要保存每一帧的所有像素数据。

    比如一个30FPS(Frame Per Second),像素大小为(480*240)的逐帧动画,用户需要保存30张图片的像素数据,并在1秒内将这30张图片顺序播放。

    其缺点主要有2点:

    1. 所占空间较大,对于上面这个动画,需要占用30*480*240*pixel大小的空间。当然也可以将这些像素打包到一起减少空间,比如gif格式等。
    2. 渲染时需要替换每一帧的纹理,很容易造成CPU计算负担过重(State Validation阶段)引起卡顿。

    从广义上来说,所有的视频都属于这个范畴。而在Android中,系统提供了AnimationDrawable这个类来实现帧动画。

  • 关键帧动画:

    关键帧动画就是不保存动画的每一帧,而通过前后两个关键帧来自动计算中间的过度画面。计算机可以根据前后两个关键帧的数据自动插值补全中间的动画。

    比如以位移动画来说,一个图像从a点移动到b点,在a点会有一个关键帧,在b点会有另一个关键帧,至于关键帧之间的中间帧,计算机会自动插值计算。还可以通过更改插值器等操作来修改这一过程。

    对比逐帧动画,由于实现关键帧动画不需要保存每一帧图像,只需要基础纹理和关键帧的数据,所以占用的空间较少,这是它的优点。

    相应的,由于关键帧动画中间的数据都通过计算而来,对于比较复杂的动画,对于CPU的负担会比较重,容易造成卡顿情况,比如加入动态蒙层、实时阴影的动画,由于需要多次渲染(即最终的一幅画面要渲染好几次才能得到),由于要遍历渲染管线好几次,CPU的负担已经远超过帧动画替换每一帧图像的开销了,这时候可以考虑用逐帧动画代替。

    而对于简单的动画,无论从所占空间的大小,还是运行的效率,都是关键帧动画要更好一些。

    在Android中,系统提供了View Animation(视图动画),(Property Animation)属性动画,Drawable Callback这些接口来实现关键帧动画。注:当然也可以通过这些接口更新每一帧的图像实现逐帧动画,这里分类时暂不考虑这种情况。

  • 小结:

    可以认为逐帧动画的每一帧图像都是在其他地方预先渲染好的(PS,AE等),而关键帧动画的每一帧图像依赖于显示设备的实时计算,这是它们的本质区别。

2.动画原理

动画的原理其实很简单,人的眼睛对图像有短暂的记忆效应,所以当眼睛看到多张图片连续快速的切换时,就会被认为是一段连续播放的动画了。所以要实现动画,只要保证每一帧画面有变化(无论是通过预先渲染好画面的帧动画还是通过实时计算得到每一帧画面的关键帧动画)即可。

(1).In Android

Android的消息事件循环是在ActivityThread的Main函数中,通过Looper的Loop函数,利用epoll机制处理消息事件的。

Android提供了以下几种接口可以实现动画:

  1. AnimationDrawable (逐帧动画)

  2. 视图动画(关键帧动画)

  3. 属性动画(关键帧动画)

  4. Drawable.Callback接口(关键帧动画)

  5. 直接通过Handler向Looper的MessageQueue队列发送消息,更新下一帧图像(逐帧动画或者关键帧动画都可实现)

其实本质上1,2,3,4 都是Android给开发者封装的接口,底层都是通过第5来实现的。

这里以视图动画与属性动画为例:

最终都是通过计算下一帧的图像数据,然后通过Handler通知消息队列,以便下次绘图时画出更新数据后的下一帧图像,这样实现动画。

Lottie源码解析

1.Lottie简介

Lottie是一个同时支持Android和IOS设备的一个开源动画框架。用户只要先用Adobe After Effects 软件做出动画,再将动画文件通过Bodymovin导出为json文件,就可以通过Lottie来解析这些json文件实现动画了。

Lottie最新版本已经支持API 14,包体大小也比较小,最新版本导出的AAR包体积为171KB。

2.基本用法

1
2
3
4
LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
// hello-world.json就是AE导出的动画数据,导出的图像也要放在assets下面。
animationView.setAnimation("hello-world.json");
animationView.loop(true);

LottieAnimation是依靠LottieDrawable实现的,也可以直接使用LottieDrawable。

1
2
3
4
LottieDrawable drawable = new LottieDrawable();
LottieComposition.Factory.fromAssetFileName(getContext(), "hello-world.json", (composition) -> {
drawable.setComposition(composition);
});

用法就是这么简单。

3.实现原理

开门见山,先说原理,再分析源码

Bodymovin导出的json文件定义了动画的关键帧数据,Lottie用LottieComposition这个类解析json动画数据。得到关键帧数据后,通过插值器计算出下一帧的数据,更新数据,再调用invalidate刷新画面。

是的,原理也是这么简单。

4.源码分析

(1).解析json文件

调用动画第一步是要反序列化json代码。

1
2
3
LottieComposition.Factory.fromAssetFileName(getContext(), "hello-world.json", (composition) -> {
drawable.setComposition(composition);
});

LottieComposition负责解析具体的json数据,并用这些成员变量保存到内存中,这些成员变量保存了从json解析出来动画的关键帧数据,Lottie这里支持同步和异步加载。

1
2
3
4
5
6
7
8
9
private final Map<String, List<Layer>> precomps = new HashMap<>();
private final Map<String, ImageAsset> images = new HashMap<>();
private final LongSparseArray<Layer> layerMap = new LongSparseArray<>();
private final List<Layer> layers = new ArrayList<>();
private final Rect bounds;
private final long startFrame;
private final long endFrame;
private final int frameRate;
private final float scale;

AE导出的Json文件大概是这样的,这里以位移动画的json文件举例:

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
{
"assets": [
{
"id": "image_0", //图片的ID,layer获得图片的标识
"w": 400, //图片的宽高
"h": 211,
"u": "images/", //图片路径,实际未被使用
"p": "img_0.png" //图片文件名
}
],
"layers": [ //每一层(每一个layer都要draw一次)
{
"ddd": 0,
"ind": 0, //layer的ID,唯一
"ty": 2, //layer type:Solid,Image,Text,Shape等等
"nm": "weaccept.jpg", //layer名字
"cl": "jpg",
"refId": "image_0",
"ks": { //关键帧
"o": { //透明度Opacity
... //省略了,记录了每一个关键帧的透明度,下面类似
},
"r": { //旋转Rotation
...
},
"p": { //位置Position
...
},
"a": { //锚点Anchor
...
},
"s": { //Scale
...
},
]
}
},
"ao": 0,
"ip": 0,
"op": 61.0000024845809,
"st": 0,
"bm": 0,
"sr": 1
}
],
"v": "4.5.0",
"ddd": 0,
"ip": 0, //开始帧
"op": 61.0000024845809, //结束帧
"fr": 29.9700012207031, //帧率fps
"w": 600,
"h": 600
}

大致含义如上面注释,如果需要知道具体的每个字段含义,可参阅Bodymovin这个开源项目。

(2).将反序列化的Json动画数据封装成关键帧对象

当Json文件被反序列化成LottieComposition后,会调用

1
drawable.setComposition(composition);

这里的drawable是LottieDrawable对象,它的setComposition方法如下:

1
2
3
4
5
6
7
boolean setComposition(LottieComposition composition) {
...
buildLayersForComposition(composition);
...
setProgress(getProgress());
return true;
}

然后会调用LottieDrawable的buildLayersForComposition方法,这个方法如下:

1
2
3
4
5
6
7
8
private void buildLayersForComposition(LottieComposition composition) {
...
for (int i = composition.getLayers().size() - 1; i >= 0; i--) {
...
layerView = new LayerView(layer, composition, this, canvasPool);
...
}
}

在这里会按LottileComposition的layers创建每个layer对象。Layer对象可以理解为需要绘制的每一层,并最终调用AnimatableLayer的addAnimation方法,对于每个AnimatableLayer对象初始化它的成员变量animations。

1
2
3
4
5
class AnimatableLayer extends Drawable {
...
private final List<BaseKeyframeAnimation<?, ?>> animations = new ArrayList<>();
...
}

这里的BasekeyframeAnimation就是最终保存的关键帧对象。

(3).动画调用流程

LottieDrawable调用PlayAnimation,该方法如下:

1
2
3
4
5
6
7
8
9
void playAnimation() {
if (layers.isEmpty()) {
playAnimationWhenLayerAdded = true;
reverseAnimationWhenLayerAdded = false;
return;
}
animator.setCurrentPlayTime((long) (getProgress() * animator.getDuration()));
animator.start();
}

会调用animator的start()方法,animator是在LottieDrawable中定义的属性动画,它的updatelistener如下:

1
2
3
4
5
6
7
8
9
10
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override public void onAnimationUpdate(ValueAnimator animation) {
if (systemAnimationsAreDisabled) {
animator.cancel();
setProgress(1f);
} else {
setProgress((float) animation.getAnimatedValue());
}
}
});

开启后会调用setProgress方法,setProgress方法是LottieDrawable基类AnimatableLayer中的方法,如下

1
2
3
4
5
6
7
8
9
10
public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
this.progress = progress;
// LottieDrawable的animations对象为空,只有需要绘制的layer
for (int i = 0; i < animations.size(); i++) {
animations.get(i).setProgress(progress);
}
for (int i = 0; i < layers.size(); i++) {
layers.get(i).setProgress(progress);
}
}

而LottieDrawable对象的animations成员变量为空,这里会调用LottieDrawable的layers成员变量:

1
final List<AnimatableLayer> layers = new ArrayList<>();

这里的layers会调用AnimatableLayer的另一个子类LayerView的setProgress方法。

实现如下:

1
2
3
4
5
6
7
@Override public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
progress *= layerModel.getTimeStretch();
super.setProgress(progress);
if (matteLayer != null) {
matteLayer.setProgress(progress);
}
}

这里又会调用它的父类的setProgress方法,即AnimatableLayer的setProgress方法:

1
2
3
4
5
6
7
8
9
10
11
public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
this.progress = progress;
// 每层的animations不为空,这里也是保存关键帧数据的地方
for (int i = 0; i < animations.size(); i++) {
animations.get(i).setProgress(progress);
}
// 每一层的的layers通常为空
for (int i = 0; i < layers.size(); i++) {
layers.get(i).setProgress(progress);
}
}

对于每层layer的animations,这个就是保存保存关键帧的地方:

1
private final List<BaseKeyframeAnimation<?, ?>> animations = new ArrayList<>();

即步骤2中所得到的关键帧。然后会调用BaseKeyframeAnimation具体实现类相应的setProgress方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// BaseKeyframeAnimation的setProgress方法
void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
if (progress < getStartDelayProgress()) {
progress = 0f;
} else if (progress > getEndProgress()) {
progress = 1f;
}
if (progress == this.progress) {
return;
}
this.progress = progress;
A value = getValue();
for (int i = 0; i < listeners.size(); i++) {
listeners.get(i).onValueChanged(value);
}
}

这里以Color关键帧ColorKeyframeAnimation为例,它的getValue()方法如下:

1
2
3
4
5
@Override public Integer getValue(Keyframe<Integer> keyframe, float keyframeProgress) {
int startColor = keyframe.startValue;
int endColor = keyframe.endValue;
return GammaEvaluator.evaluate(keyframeProgress, startColor, endColor);
}

这里按json文件提供的关键帧数据计算得到动画在某一过程中的value,并调用其onValueChanged方法:

1
2
3
4
5
6
7
8
9
10
11
12
private final KeyframeAnimation.AnimationListener<Integer> colorChangedListener =
new KeyframeAnimation.AnimationListener<Integer>() {
@Override
public void onValueChanged(Integer value) {
onColorChanged();
}
};
// 设置paint在对应progress的颜色,并通知UI重绘
private void onColorChanged() {
paint.setColor(color.getValue());
invalidateSelf();
}

至此,完整整个动画流程的调用。

三、总结

Lottie这样作为一款开源动画库,解决了UE和RD对于同一份动画文件要重复实现2次的问题,可以说直接提高了开发效率,但需要注意的是,Lottie目前只支持AE的部分属性:

  • Keyframe Interpolation 关键帧插值
  • Solids 固态层
  • Masks 蒙版
  • Track Mattes 遮罩模式
  • Parenting 父子关系
  • Shape Layers 形状层
  • Stroke (shape layer) 描边(形状层)
  • Fill (shape layer) 填充(形状层)
  • Trim Paths (shape layer) 修剪路径(形状层)

如果UE在制作动画的过程中,在AE中用到了Lottie不支持的属性,那么就不能成功加载。

另外从效率上来说,和GIF等帧动画相比,由于逐帧动画需要每帧都替换纹理,所以对于没有蒙版遮罩等简单的动画,Lottie的运行效率是要好于帧动画的。当然这个临界点就是CPU每帧替换纹理的State validation开销大于CPU计算得出下一帧数据的开销。

另外和自己实现属性动画相比,多了一个解析JSON的开销,不过Lottie对此也提供了异步加载以及缓存机制来减少这一点。

在这里简单的总结一下Lottie的优缺点。

优点:

  1. 如果是没有mask和mattes等简单的动画,那么性能和内存非常好,没有bitmap创建,大部分操作都是简单的cavas绘制。

缺点:

Lottie发布后在Github上的ISSUE大多已经被解决,仍然open的主要有以下几点:

  1. 不支持硬件加速(最新的Lottie版本已经支持)
    由于Lottie使用了一些不支持硬件加速的API,所以暂时不能支持硬件加速。这一点和开启硬件加速的动画相比性能损失较大。
  2. 对于存在蒙版、遮罩等复杂的动画,性能较差

附:Android开发中一个常见的误区就是不能在非UI线程更新UI,这句话其实是不对的,正确的说法应该是不能在没有绑定渲染API context的线程更新UI。
而在非UI线程更新UI是可以做到的,拿开启硬件加速后的渲染流程举例。不能在非UI线程调用渲染方法是因为底层Opengl的context绑定了UI线程,而Opengl的同一个context只能绑定一个线程,在没有绑定其context的线程调用它的方法当然会出错。如果将OpenGl的context绑定另一个线程,是可以在那个线程调用渲染方法的,当然需要处理一些线程交互问题。

Android虚拟机中的内存分配与OOM问题

Posted on 2017-04-02 |

背景知识

Android中每个App默认情况下是运行在一个独立进程中的, 而这个独立进程正是从Zygote孵化出来的VM进程, 也就是说, 也就是说每个Android APP在运行时会启动一个Java虚拟机。并且系统会给它分配固定的内存空间(手机厂商会根据手机的配置情况来对其进行调整)。

一、Android VM的内存空间

Android是一个多任务系统, 为了保证多任务的运行, Android给每个App可使用的Heap大小设定了一个限定值.这个值是系统设置的prop值, 保存在System/build.prop文件中. 一般国内的手机厂商都会做修改, 根据手机配置不同而不同, 可以直接打开查看与修改。

其中和虚拟机内存相关的主要有以下三个:

  1. dalvik.vm.heapstartsize

    – App启动后,系统分配给它的Heap初始大小,随着App使用可增加。

  2. dalvik.vm.heapgrowthlimit

    – 默认情况下, App可使用的Heap的最大值, 超过这个值就会产生OOM.

  3. dalvik.vm.heapsize

    – 如果App的manifest文件中配置了largeHeap属性, 那么App可使用的Heap的最大值为此项设定值。

    1
    2
    3
    4
    <application
    android:largeHeap="true">
    ...
    </application>

所以对于同一个手机,不开启largeHeap属性时与多进程时,每个APP的虚拟机分配的内存的上限都是heapgrowthlimit。

1.查看内存的API

Android在ActivityManager类中提供了API可以运行时获取这些属性值,如下:

1
2
3
4
5
//ActivityManager的getMemoryClass()获得内用正常情况下内存的大小,即heapgrowthlimit的值
//ActivityManager的getLargeMemoryClass()可以获得开启largeHeap最大的内存大小,即heapsize的指
ActivityManager activityManager = (ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE);
activityManager.getMemoryClass();
activityManager.getLargeMemoryClass();

二、Android VM内存分配流程

虚拟机分配内存的具体源码可以AOSP的Heap.cpp文件中查看:

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
/* Try as hard as possible to allocate some memory.
*/
static void *tryMalloc(size_t size)
{
void *ptr;
//TODO: figure out better heuristics
// There will be a lot of churn if someone allocates a bunch of
// big objects in a row, and we hit the frag case each time.
// A full GC for each.
// Maybe we grow the heap in bigger leaps
// Maybe we skip the GC if the size is large and we did one recently
// (number of allocations ago) (watch for thread effects)
// DeflateTest allocs a bunch of ~128k buffers w/in 0-5 allocs of each other
// (or, at least, there are only 0-5 objects swept each time)
ptr = dvmHeapSourceAlloc(size);
if (ptr != NULL) {
return ptr;
}
/*
* The allocation failed. If the GC is running, block until it
* completes and retry.
*/
if (gDvm.gcHeap->gcRunning) {
/*
* The GC is concurrently tracing the heap. Release the heap
* lock, wait for the GC to complete, and retrying allocating.
*/
dvmWaitForConcurrentGcToComplete();
} else {
/*
* Try a foreground GC since a concurrent GC is not currently running.
*/
gcForMalloc(false);
}
ptr = dvmHeapSourceAlloc(size);
if (ptr != NULL) {
return ptr;
}
/* Even that didn't work; this is an exceptional state.
* Try harder, growing the heap if necessary.
*/
ptr = dvmHeapSourceAllocAndGrow(size);
if (ptr != NULL) {
size_t newHeapSize;
newHeapSize = dvmHeapSourceGetIdealFootprint();
//TODO: may want to grow a little bit more so that the amount of free
// space is equal to the old free space + the utilization slop for
// the new allocation.
LOGI_HEAP("Grow heap (frag case) to "
"%zu.%03zuMB for %zu-byte allocation",
FRACTIONAL_MB(newHeapSize), size);
return ptr;
}
/* Most allocations should have succeeded by now, so the heap
* is really full, really fragmented, or the requested size is
* really big. Do another GC, collecting SoftReferences this
* time. The VM spec requires that all SoftReferences have
* been collected and cleared before throwing an OOME.
*/
//TODO: wait for the finalizers from the previous GC to finish
LOGI_HEAP("Forcing collection of SoftReferences for %zu-byte allocation",
size);
gcForMalloc(true);
ptr = dvmHeapSourceAllocAndGrow(size);
if (ptr != NULL) {
return ptr;
}
//TODO: maybe wait for finalizers and try one last time
LOGE_HEAP("Out of memory on a %zd-byte allocation.", size);
//TODO: tell the HeapSource to dump its state
dvmDumpThread(dvmThreadSelf(), false);
return NULL;
}

具体流程如下:

  1. 尝试分配,如果成功则返回,失败则转入步骤2
  2. 判断是否gc正在进行垃圾回收,如果正在进行则等待回收完成之后,尝试分配。如果成功则返回,失败则转入步骤3
  3. 自己启动gc进行垃圾回收,这里gcForMalloc的参数是false。所以不会回收软引用,回收完成后尝试分配,如果成功则返回,失败则转入步骤4
  4. 调用dvmHeapSourceAllocAndGrow尝试分配,这个函数会扩张堆的大小,失败转入步骤5
  5. 进入回收软引用阶段,这里gcForMalloc的参数是ture,所以需要回收软引用。然后再调用dvmHeapSourceAllocAndGrow尝试分配,如果失败则抛出OOM

小结

所以产生OOM时,一定是java的堆中 已有的内存 + 申请的内存 >= heapgrowthlimit导致的,不会因为手机目前物理内存是否紧张而改变 - 当物理内存非常紧张时系统会通过LowMemory Killer杀掉一些低优先级的进程。

相应的,物理内存非常充足的情况也会有OOM的情况发生。

三、出现OOM的建议解决方案

当APP出现OOM时,建议可以从以下两个方向来处理:

  1. 排查内存泄露问题
  • 排查各个功能是否内存泄露情况,可以通过Android Studio中的MemoryMonitor功能进行分析,Memory Monitor也集成了HPROF Viewer和Allocation Tracker可以分析内存快照与内存分配追踪。另外推荐一个工具,square公司开源的leakcanary,非常简洁好用。
  • 排查进程初始化时就直接申请并常驻内存的对象以及其他功能里申请的static对象或者单例对象的必要性。
  1. 内存优化
    按照谷歌在youtube上发布的性能优化典范之内存篇,优化各功能的内存,或可参照胡凯的总结。大致有以下这些,具体请参见原文:

    • 减少对象的内存占用

      • 使用更加轻量的数据结构
      • 避免在Android里使用enum
      • 减少Bitmap对象的内存占用
      • 使用更小的图片
    • 内存对象的重复利用

      • 复用系统自带的资源

      • ListView中对ConvertView的复用

      • Bitmap对象的复用

      • 避免在ondraw方法里执行对象的创建

      • StringBuilder代替String

    • 避免对象的内存泄露

      • 注意Activity的泄露
      • 考虑使用Applicaiton Context代替Activity Context
      • 注意临时Bitmap对象的及时回收
      • 注意监听器的注销
      • 注意缓存容器里的对象泄露
      • 注意Webview的泄露
      • 注意Cursor对象的及时关闭
    • 内存使用策略的优化

      • 谨慎使用large heap
      • 综合考虑设备的内存阈值与其他因素设计合适的缓存大小
      • onLowMemory与onTrimMemory
      • 资源文件需要选择合适的文件夹进行存放
      • Try catch某些大内存分配的操作
      • 谨慎使用static对象
      • 特别留意单例对象中不合理的持有
      • 珍惜Services资源
      • 优化布局层次,减少内存消耗
      • 谨慎使用“抽象”编程
      • 使用nano protobufs序列化数据
      • 谨慎使用依赖注入框架
      • 谨慎使用多进程
      • 使用ProGuard来剔除不需要的代码
      • 谨慎使用第三方libraries
      • 考虑不同的实现方式来优化内存占用

减少Android NDK开发中SO包大小的几种方法

Posted on 2017-03-02 |

背景

这周在做Yoga包的压缩工作。Yoga本身是用BUCK脚本编译的,而最终编译出几个包大小大总共约为7M,不能满足项目中对于APK大小的限制,因此需要对它进行压缩。这里先将Yoga编译脚本用CMAKE重新改写,以便可以在android studio中直接使用并输出一个AAR的包。后面又对它进行了压缩,最终将Yoga包的大小压缩到200多KB。

下面整理了一些可以用于减少NDK开发中Android SO包大小的方法:

1.STL的使用方式

对于C++的library,引用方式有2种:

  • 静态方式(static)

  • 动态方式(shared)

其中,静态方式在编译时会将用到的相关代码直接复制到目的文件中;而动态方式则会将相关的代码打成so文件,以便多次引用。由于编译器在编译时并不能知道所有被引用的地方,所以同时会打入了很多不相关的代码。

所以,如果项目中引用library的函数较多时,用动态方式可以避免多次拷贝,节省空间。相反,则直接使用静态方式会更节省空间。

NDK开发中,可以通过gradle的设置来配置:

1
2
3
4
5
6
7
8
defaultConfig{
externalNativeBuild{
cmake{
// gnustl_shared 动态
arguments "-DANDROID_STL=gnustl_static"
}
}
}

在Yoga中,项目里的stl使用较少时,安卓运行时使用static的方式,而不是shared,所以这里采用static的方式。在采取了这种方式后,包的大小从2.7M缩减到了2M。

2.不使用Exception和RTTI

C++的exception和RTTI功能在NDK中默认是关闭的,但是可以通过配置打开的。

Android.mk:

1
APP_CPPFLAGS += -fexceptions -frtti

CMake:

1
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fexceptions -frtti")

Exception和RTTI会显著的增加包的体积,所以非必须的时候,没有必要使用。

RTTI

通过RTTI,能够通过基类的指针或引用来检索其所指对象的实际类型,即运行时获取对象的实际类型。C++通过下面两个操作符提供RTTI。

(1)typeid:返回指针或引用所指对象的实际类型。

(2)dynamic_cast:将基类类型的指针或引用安全的转换为派生类型的指针或引用。

在yoga中,RTTI的选项是默认打开的,而代码中其实并没有用到相关的功能,这里可以直接关闭。

Exception

使用C++的exception会增加包的大小,而目前JNI对C++的exception的支持是有bug的,比如下面这段代码就会引起程序的crash(对于低版本的android NDK)。因此要在程序中引入exception要自己实现相关逻辑,yoga就是这么做的,这个又增加了一些包体大小。对于开发者来说,exception可以帮助快速定位问题,而对于使用者并不是那么重要,这里可以去掉。

1
2
3
4
5
try {
...
} catch (std::exception& e) {
env->ThrowNew(env->FindClass("java/lang/Exception"), "Error occured");
}

在yoga中,在关闭RTTI和Exception功能并把exception相关的代码都去掉后,包的大小从2M缩减到的1.8M。

3.使用 gc-sections去除没有用到的函数

去除未使用的代码显然可以减少包体的大小,而在NDK的开发中,并不需要手动的来做这一点。可以开启编译器的gc-sections选项,让编译器自动的帮你做到这一点。

编译器可以配置自动去除未使用的函数和变量,以下是配置方式:

CMake:

1
2
3
4
5
# 去除未使用函数与变量
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffunction-sections -fdata-sections")
set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS}")
# 设置去除未使用代码的链接flag
SET_TARGET_PROPERTIES(yoga PROPERTIES LINK_FLAGS "-Wl,--gc-sections")

Android.mk:

1
2
3
LOCAL_CPPFLAGS += -ffunction-sections -fdata-sections
LOCAL_CFLAGS += -ffunction-sections -fdata-sections
LOCAL_LDFLAGS += -Wl,--gc-sections

4.去除冗余代码

在NDK中,链接器还有一个选项 “-icf = safe”,可以用于去除代码中的冗余代码。但是要注意的是,这个选项也有可能去除定义好的inline函数,这里必须要做好权衡。

下面是配置方式:

CMake:

1
SET_TARGET_PROPERTIES(yoga PROPERTIES LINK_FLAGS "-Wl,--gc-sections,--icf=safe")

Android.mk:

1
LOCAL_LDFLAGS += -Wl,--gc-sections,--icf=safe

5.设置编译器的优化flag

编译器有个优化flag可以设置,分别是-Os(体积最小),-O3(性能最优)等。这里将编译器的优化flag设置为-Os,以便减少体积。

CMake:

1
2
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Os")
set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS}")

Android.mk

1
2
LOCAL_CPPFLAGS += -Os
LOCAL_CFLAGS += -Os

在采用了3,4,5这几种方式后,Yoga包的大小从1.8M减少到了1.7M。这里减少的比较少是因为Yoga在这方面已经做的挺好了,其他的库可能会更有效。

6.设置编译器的 Visibility Feature

还有个减少包体大小的方法,就是设置编译器的visibility feature。

Visibility Feature就是用来控制在哪些函数可以在符号表中被输入,由于C++并不是完全面向对象的,非类的方法并没有public这种修饰符,因此,要用Visibility Feature来控制哪些函数可以被外部调用。而JNI提供了一个宏-JNIEXPORT来控制这点。所以只要对函数加上这个宏,像这样:

1
2
3
// JNIEXPORT就是控制可见的宏
// JNICALL在NDK这里没有什么意义,只是个标识宏
JNIEXPORT void JNICALL Java_ClassName_MethodName(JNIEnv *env, jobject obj, jstring javaString)

然后在编译器的FLAGS选项开启 -fvisibility = hidden 就可以。这样,不仅可以控制函数的可见性,并且可以减少包体的大小。

CMake:

1
2
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
set(CMAKE_CXX_FLAGS "${CMAKE_C_FLAGS}")

7.设置编译器的Strip选项

我在把Yoga库编译成AAR包的过程中发现,它的体积明显会大于最后打包进APK的大小,这点非常不合理,但是无法找到原因。

最终搜索到这是谷歌NDK的一个bug,在打AAR包的过程中,无论是debug版本还是release版本,NDK toolchain不会自动的把方便调试的C++ 符号表(Symbol Table)中数据删除,而只会在打APK包的时候进行这一操作。这就导致了打成的AAR包中的SO体积明显偏大。

详细描述可以参见这个ISSUE: https://code.google.com/p/android/issues/detail?id=222831

找到原因后这个问题就很好解决了,可以手动的在链接选项中加入 strip参数,配置如下所示:

1
SET_TARGET_PROPERTIES(yoga PROPERTIES LINK_FLAGS "-Wl,--gc-sections,--icf=safe,-s")

在强制进行strip操作后,将Yoga包的体积从1.7M成功减少到了282KB。

8.去除C++代码中的iostream相关代码

使用STL中的iostream相关库会明显的增加包的体积,而NDK本身是有预编译库(android/log.h)可以代替这一功能的,在Yoga这里,用log的函数代替了iostream中的所有函数,如:

1
2
3
//代替所有的iostream库里函数
//cout << obj->toString() << endl;
__android_log_print(ANDROID_LOG_VERBOSE,"Yoga","Node is: %s",obj->toString().c_str());

在做完代替之后,yoga包的体积从282KB减少到了218KB。

总结

在做完这一系列工作后,最终成功的压缩了Yoga包的体积,从几M到最后输出一个218KB的AAR包提供使用。以上几种方法并不局限于Yoga包的缩减。在NDK开发中,要缩减SO包的体积都可以按照这几种方式尝试一下。

Ma Yuancheng

Programmer & Dotaer

4 posts
2 tags
GitHub
© 2019 Ma Yuancheng
Powered by Hexo
|
Theme — NexT.Mist v5.1.4