Android 开发艺术探索学习笔记(四)

结合 官方文档 阅读《Android 开发艺术探索》时所做的学习笔记。本篇记录第 6、7、12 章:Drawable动画Bitmap 相关。

Drawable

Drawable 是一种抽象的概念,表示一种可以在屏幕上进行绘制 (Draw) 的图像,常见的颜色和图片都可以是 Drawable。

Drawable 一般没有大小的概念,当作为 View 的 background 的时候,它会被拉伸至 View 同等大小。不过它有两个参数,getIntrinsicWidthgetIntrinsicHeight 表示内在的宽高,可以理解为默认宽高,比如显示一张图片时,这个值所对应的宽高就是当前图片的宽高,当作为 ColorDrawable 的时候该值则为 -1。

Drawable 的分类

Drawable 类型比较多,而且也没必要记住所有属性,需要时查文档就可以了:

自定义 Drawable

只要重写 draw() 方法就可以了,例子见:android-art-res / Chapter_6

扩展阅读

Animation

动画可以分为属性动画 (Property Animation) 和 View 动画。View 动画通过对图像做各种变换(平移、旋转、缩放、透明度)从而产生动画效果,而属性动画则通过动态修改对象的属性来达到动画效果。推荐优先使用属性动画。

View 动画

Tween animation

补间动画分为平移动画、缩放动画、旋转动画和透明度动画,它们既可以通过 XML 定义也可以通过代码动态创建。xml 文件一般放在 res/anim 目录下。

Frame Animation

帧动画其实就是一组预先定义好的图片,然后逐帧显示。使用帧动画如果图片过大,可能会导致 OOM,所以需要注意图片尺寸的问题。xml 文件一般放在 res/drawable 目录下。

特殊的 Tween Animation

LayoutAnimation

主要用于 ViewGroup,使用后子元素出场时会显示该动画,比如用于 ListView 或者 RecyclerView,为 item 设置进入时的从左往右进入,或者先变大然后恢复的动效。

扩展阅读:Auto animate layout updates

Activity 切换效果

我们可以通过在 activity 启动以及结束前使用 overridePendingTransition(enterAnimId, exitAnimId) 或者 ActivityOptions.makeCustomAnimation(context, enterAnimId, exitAnimId) 来指定 Activity 的切换效果。

另外,Android 5.0 之后还支持共享元素的过渡效果,具体请阅读:Start an activity using an animation

属性动画

属性动画与 View 动画不同,它对作用对象进行了扩展,因此可以对任何对象做动画,甚至可以没有对象,而且实现的效果也更加丰富多样。

属性动画的使用

常用的类有 ValueAnimatorObjectAnimatorAnimatorSet。其中 ObjectAnimator 继承自 ValueAnimatorAnimatorSet 是一组动画集合。对应的 xml 标签分别是 <objectAnimator> <animator> <set>,xml 文件一般放在 res/animator 目录下。

不过属性动画使用代码进行操作更为简便,比如垂直平移一个 View 只要这样:

1
ObjectAnimator.ofFloat(view, "translationY", 100f).start()

插值器和估值器

插值器我理解为动画的变化方式(速率等),而估值器表示某一个时间节点下的变化的值。系统自带的插值器有:TimeInterpolator / LinearInterpolator / AccelerateDecelerateInterpolator 等,如果我们想要自定义动画效果,一般需要实现 InterpolatorTypeEvaluator

属性动画的监听器

我们可以实现对动画播放过程的监听,主要通过 Animator.AnimatorListener,可以监听动画的开始、结束、取消、重新播放。 ValueAnimator.AnimatorUpdateListener,可以监听动画的更新,通过 ValueAnimator.getAnimatedValue() 获取变化值,我们还可以利用这个方法自定义出一些特殊效果。

属性动画的工作原理

通过获取 View 上的该属性的初始值(如果没有提供初始值则调用属性的 get 方法)和最终值(即我们 set 进去的值,xml 中的 toValue),然后以动画的效果,多次调用其 set 方法,直到达到最终值。源码解析就不贴了。

对于某些属性,即使提供了 set get 方法,但是对其做属性动画依旧没效果,原因是该属性不会带来 UI 显示上的变化,自然看上去就像没有效果了,比如 TextView 的 setWidth 和 getWidth,这个方法指定的是最大宽度,而不是实际显示的宽度,所以对它做属性动画不会有效果。

我们可以通过两种方式来改变这种状况,一种是通过包装原始对象,并为其提供 set 和 get 方法。另一种是通过上面提到的 ValueAnimator.AnimtorUpdateListener 加上估值器,手动修改对应的属性来实现效果。例子见:android-art-res / Chapter_7。推荐使用第一种,因为更容易复用。

扩展阅读

Bitmap 的加载和缓存

Bitmap 的高效加载

我们通过 Bitmap 加载图片的时候,一般都需要考虑图片大小的问题,图片越大占用的内存也就越多,一不小心还有可能造成 OOM,所以对于大图来说一般会做缩放后再显示。

核心思想是,首先通过为 BitmapFactory.OptionsinJustDecodeBounds 设置 true,对图片进行采样获取到宽高(不会为图片像素点分配内存,因此不会消耗太大),然后再根据实际需要显示的宽高,计算出合理的缩放倍数,然后再对图片进行真正的加载。

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
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;

if (height > reqHeight || width > reqWidth) {

final int halfHeight = height / 2;
final int halfWidth = width / 2;

// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}

return inSampleSize;
}

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {

// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);

// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}

BitmapFactory 除了 decodeResource() 这一从资源中读取 Bitmap 的方式外,还可以使用 decodeFile()decodeStream()decodeByteArray() 这几种方式,分别表示从文件、字节流、Byte 数组中读取并加载 Bitmap,这些方法最终调用的都是 native 方法,由 Android 底层实现。

扩展阅读

内存缓存和磁盘缓存

在 RecyclerView 或者 ViewPager 中加载大量图片时,如果不做特殊处理,由于 View 的复用以及垃圾回收机制的存在,屏幕之外的图片很快会被回收掉,所以为了让页面保持流畅(不出现白屏也不会因为重复加载图片导致卡顿),我们就需要使用缓存来加速图片的恢复加载。

内存缓存

通过内存缓存(LruCache),我们可以将 Bitmap 缓存在应用内存中来提升加载速度。LruCache 中使用一个 LinkedHashMap 保存最近引用过的对象,当引用数量超出容量限制的时候就会将最近最少使用的对象移除。

使用 LruCache 缓存图片的时候需要考虑以下问题:

  • 图片大小多少,占用多少内存?
  • 一屏加载多少图片?有多少是需要预加载的?
  • 你的 activity 除了图片之外,其他部分耗内存吗?
  • 有哪些图片是频繁访问的?如果有特定图片是频繁访问的,可以选择常驻到内存缓存中。另外,如果有确定的访问频率不一致的图片组,可以考虑使用多个 LruCache
  • 如果是特别大的图片,可以考虑使用多种清晰度的图片,先加载低清晰度图片,然后再使用后台任务加载高清度的图片。
  • 如果是本地图片,需要考虑在不同屏幕大小和屏幕密度上设备是否表现一致。

使用 LruCache 加载 Bitmap 的例子:

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
private LruCache<String, Bitmap> memoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
...
// Get max available VM memory, exceeding this amount will throw an
// OutOfMemory exception. Stored in kilobytes as LruCache takes an
// int in its constructor.
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

// Use 1/8th of the available memory for this memory cache.
final int cacheSize = maxMemory / 8;

memoryCache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.getByteCount() / 1024;
}
};
...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap);
}
}

public Bitmap getBitmapFromMemCache(String key) {
return memoryCache.get(key);
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
...
// Decode image in background.
@Override
protected Bitmap doInBackground(Integer... params) {
final Bitmap bitmap = decodeSampledBitmapFromResource(
getResources(), params[0], 100, 100));
addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
return bitmap;
}
...
}

加载 Bitmap 前先判断是否有缓存:

1
2
3
4
5
6
7
8
9
10
11
12
public void loadBitmap(int resId, ImageView imageView) {
final String imageKey = String.valueOf(resId);

final Bitmap bitmap = getBitmapFromMemCache(imageKey);
if (bitmap != null) {
mImageView.setImageBitmap(bitmap);
} else {
mImageView.setImageResource(R.drawable.image_placeholder);
BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
task.execute(resId);
}
}

磁盘缓存

内存缓存虽然速度快,但是我们也不能完全只依赖它,因为用户手机可能内存很小,应用随时可能被杀死(用户离开一段时间或者有更高优先级的任务占用了内存等等),当用户重新打开你的页面的时候,又要重新加载图片。

DiskLruCache 可以帮我们把加载过的 Bitmap 持久化到存储空间中从而减少用户重新加载的等待时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
private DiskLruCache diskLruCache;
private final Object diskCacheLock = new Object();
private boolean diskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";
private static final int APP_VERSION = 1;
private static final int VALUE_COUNT = 1;

@Override
protected void onCreate(Bundle savedInstanceState) {
...
// Initialize memory cache
...
// Initialize disk cache on background thread
File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
new InitDiskCacheTask().execute(cacheDir);
...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
@Override
protected Void doInBackground(File... params) {
synchronized (diskCacheLock) {
File cacheDir = params[0];
diskLruCache = DiskLruCache.open(
cacheDir, APP_VERSION, VALUE_COUNT, DISK_CACHE_SIZE);
diskCacheStarting = false; // Finished initialization
diskCacheLock.notifyAll(); // Wake any waiting threads
}
return null;
}
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
...
// Decode image in background.
@Override
protected Bitmap doInBackground(Integer... params) {
final String imageKey = String.valueOf(params[0]);

// Check disk cache in background thread
Bitmap bitmap = getBitmapFromDiskCache(imageKey);

if (bitmap == null) { // Not found in disk cache
// Process as normal
final Bitmap bitmap = decodeSampledBitmapFromResource(
getResources(), params[0], 100, 100));
}

// Add final bitmap to caches
addBitmapToCache(imageKey, bitmap);

return bitmap;
}
...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
// Add to memory cache as before
if (getBitmapFromMemCache(key) == null) {
memoryCache.put(key, bitmap);
}

// Also add to disk cache
synchronized (diskCacheLock) {
if (diskLruCache != null && diskLruCache.get(key) == null) {
diskLruCache.put(key, bitmap);
}
}
}

public Bitmap getBitmapFromDiskCache(String key) {
synchronized (diskCacheLock) {
// Wait while disk cache is started from background thread
while (diskCacheStarting) {
try {
diskCacheLock.wait();
} catch (InterruptedException e) {}
}
if (diskLruCache != null) {
return diskLruCache.get(key);
}
}
return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
// Check if media is mounted or storage is built-in, if so, try and use external cache dir
// otherwise use internal cache dir
final String cachePath =
Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
!isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
context.getCacheDir().getPath();

return new File(cachePath + File.separator + uniqueName);
}

因为涉及到文件读写,所以速度肯定要慢一点。而且可以看到,上面 DiskLruCache 是在子线程中创建的,所以当添加或者读取的时候,我们需要使用同步锁。

扩展阅读

ImageLoader 的实现与使用

ImageLoader 的实现

一个图片加载框架一般需要考虑:

  • 图片的压缩
  • 内存缓存与磁盘缓存
  • 同步加载、异步加载
  • 通过网络加载

具体实现见:android-art-res / Chapter_12

照片墙效果

MainActivity.java

优化列表卡顿

  1. 不要在 getView() 方法中做耗时操作。比如加载图片是耗时操作,如果在 getView() 中进行加载,那么一定会导致卡顿,所以一般需要异步加载。

  2. 控制异步任务的执行频率。当用户快速滑动列表时会产生大量异步任务,随后通知主线程进行大量的 UI 更新操作,此时很容易造成卡顿。所以我们可以为列表设置 OnScrollListener.onScrollStateChanged,并在其中判断是否滑动,禁止在滑动时加载图片。

  3. 开启硬件加速。为 activity 设置 android:hardwareAccelerated="true",很多莫名的卡顿问题可能都是因为硬件加速没开。


系列文章