Drawable缓存详解

问题是什么

前段时间遇到了一个奇怪的问题,让我惊呆了。在搞清楚了原因之余,我决定顺藤摸瓜,把藤上的每只瓜撸清楚。这篇文章记录了整个过程。
直接来看个例子,先看代码:

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
public class DrawableCacheMainActivity extends AppCompatActivity {
private ImageView mIvImg;
private Button mBtnNextPage;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_drawable_cache_main);
setTitle("Drawable缓存验证");
mIvImg = (ImageView) findViewById(R.id.iv_img);
mBtnNextPage = (Button) findViewById(R.id.btn_next_page);
mIvImg.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
mIvImg.setImageDrawable(getResources().getDrawable(R.drawable.ic_drawable_cache));
Toast.makeText(DrawableCacheMainActivity.this, "refreshed", Toast.LENGTH_SHORT).show();
}
});
mBtnNextPage.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startActivity(new Intent(DrawableCacheMainActivity.this, DrawableCacheNextActivity.class));
}
});
}
}

很简单的一个页面,有两个ImageView,分别加载了同样的一张图片。
首页

点击下一页按钮,打开了第二个页面:

1
2
3
4
5
6
7
8
9
10
11
public class DrawableCacheNextActivity extends AppCompatActivity {
private ImageView mIvImg;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_drawable_cache_next);
setTitle("设置alpha值");
mIvImg = (ImageView) findViewById(R.id.iv_img);
mIvImg.getDrawable().setAlpha(128);
}
}

第二页
也很简单啊,这个页面同样加载了相同的一张资源图片,并且直接设置了alpha透明度。
这个时候,让我们返回到上个页面:

回到首页
问题来了,好像全局的相同资源的图片的alpha值都自动被改掉了,看起来好像有缓存。基于这个问题,我们来摸一摸藤上的其他几只瓜。

藤上有这些瓜

  1. 当我们在xml里通过src属性为某个ImageView设置了资源图片,或者在代码里设置了drawable,图片具体是怎么生效的?
  2. 同一个资源加载而来的drawable对象好像真的有缓存,是真的吗,内部是怎么存储的?
  3. 有什么办法能做到对某个drawable单独设置alpha而不影响其他的drawable?

详细分析

问题1

先看代码设置drawable的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ImageView.java
/**
* Sets a Bitmap as the content of this ImageView.
* @param bm The bitmap to set
*/
@android.view.RemotableViewMethod
public void setImageBitmap(Bitmap bm) {
// Hacky fix to force setImageDrawable to do a full setImageDrawable
// instead of doing an object reference comparison
mDrawable = null;
if (mRecycleableBitmapDrawable == null) {
mRecycleableBitmapDrawable = new BitmapDrawable(mContext.getResources(), bm);
} else {
mRecycleableBitmapDrawable.setBitmap(bm);
}
setImageDrawable(mRecycleableBitmapDrawable);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ImageView.java
/**
* Sets a drawable as the content of this ImageView.
* @param drawable the Drawable to set, or {@code null} to clear the content
*/
public void setImageDrawable(@Nullable Drawable drawable) {
if (mDrawable != drawable) {
mResource = 0;
mUri = null;
final int oldWidth = mDrawableWidth;
final int oldHeight = mDrawableHeight;
updateDrawable(drawable);
if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
requestLayout();
}
invalidate();
}
}

从ImageView的源码里翻到了两个方法:setImageBitmap和setImageDrawable,可以看到setImageBitmap内部调用了setImageDrawable,而setImageDrawable内部调用了updateDrawable方法,来看看这个方法:

1
2
3
4
5
6
7
8
9
10
11
private void updateDrawable(Drawable d) {
// 省略一坨代码
mDrawable = d;
if (d != null) {
d.setCallback(this);
// 省略一坨代码
applyColorMod();
// 。。。
}
// 省略一坨代码
}

把当前ImageView作为入参设置给setCallback方法。这个方法很重要,我们先卖个关子。
把drawable赋值给ImageView内部的mDrawable对象。
再执行applyColorMod方法,这个方法也很重要,我们后面再讲。
回到setImageDrawable方法,在方法的末尾执行了invalidate,大家都知道这会触发View的重绘制,于是我们看下ImageView的onDraw方法:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 省略一坨代码
if (mDrawMatrix == null && mPaddingTop == 0 && mPaddingLeft == 0) {
mDrawable.draw(canvas);
} else {
// 省略一坨代码
mDrawable.draw(canvas);
// ...
}
}

其他我不管,反正我只看到了mDrawable.draw(canvas)这句话,这里就是drawable对象被设置到ImageView上的地方了。

好了,代码里设置drawable是怎么生效的我们明白了,接下来看下xml里通过src参数配置的drawable资源是如何生效的,让我们继续到源码海洋里游一圈。
我们知道,加载xml布局是LayoutInflater干的活,那就直接来看看这个类好了,最常用的inflate方法:

1
2
3
4
5
6
7
8
9
LayoutInflater.java
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
// 省略几行代码
final XmlResourceParser parser = res.getLayout(resource);
// ...
return inflate(parser, root, attachToRoot);
// ...
}

嗯,构建了一个Resource类,这个类挺重要,内部持有了一个ResourceImpl实现类,很多对资源的加载操作都是由他来进行的。
然后是inflate重载方法,其内部调用了createViewFromTag方法:

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
LayoutInflater.java
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {
// 省略一坨代码
try {
View view;
// 先尝试从Factory来加载View,如果Factory存在的话
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
// 加载失败的话,再通过createView来加载
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (。。。) {
// 省略一坨代码
}
}

这里提一点,Factory是很有用的类,可以实现全局替换某个View为自己的View,这里不展开讲。
onCreateView内部最终调用的是createView方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
// 省略一坨代码
try {
// 省略一坨代码
clazz = mContext.getClassLoader().loadClass(
prefix != null ? (prefix + name) : name).asSubclass(View.class);
// ...
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
// ...
Object[] args = mConstructorArgs;
args[1] = attrs;
final View view = constructor.newInstance(args);
// 省略一坨代码
return view;
} catch (。。。) {
// 省略一坨代码
}
}

我们看到,最终inflate是通过反射并且传入从xml中解析出来的attrs参数来创建View对象的,那我们以ImageView为例,看看构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initImageView();
final TypedArray a = context.obtainStyledAttributes(
attrs, R.styleable.ImageView, defStyleAttr, defStyleRes);
// 就是你啦
final Drawable d = a.getDrawable(R.styleable.ImageView_src);
if (d != null) {
setImageDrawable(d);
}
// 省略一坨代码
a.recycle();
}

终于破案了,通过attrs构建TypedArray,再解出drawable资源,最终通过setImageDrawable设置到ImageView。

问题2

drawable到底有没有做缓存?要分析这个问题,我们可以直接看下drawable是怎么解析而来的。如果有缓存,那么一定会有从cache中取的操作,同时会有解析完成后写入cache的操作。那么就从drawable的加载这步入手吧。
上面我们提到,在ImageVIew的构造方法中由TypedArray解析出了src配置的drawable,跟踪下源码,发现调用了ResourcesImpl的loadDrawable方法:

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
@Nullable
Drawable loadDrawable(@NonNull Resources wrapper, @NonNull TypedValue value, int id,
int density, @Nullable Resources.Theme theme)
throws NotFoundException {
// If the drawable's XML lives in our current density qualifier,
// it's okay to use a scaled version from the cache. Otherwise, we
// need to actually load the drawable from XML.
final boolean useCache = density == 0 || value.density == mMetrics.densityDpi;
// 。。。
try {
// 。。。
final boolean isColorDrawable;
final DrawableCache caches;
final long key;
if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
&& value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
isColorDrawable = true;
caches = mColorDrawableCache;
key = value.data;
} else {
isColorDrawable = false;
caches = mDrawableCache;
key = (((long) value.assetCookie) << 32) | value.data;
}
// First, check whether we have a cached version of this drawable
// that was inflated against the specified theme. Skip the cache if
// we're currently preloading or we're not using the cache.
if (!mPreloading && useCache) {
final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
if (cachedDrawable != null) {
cachedDrawable.setChangingConfigurations(value.changingConfigurations);
return cachedDrawable;
}
}
// Next, check preloaded drawables. Preloaded drawables may contain
// unresolved theme attributes.
final Drawable.ConstantState cs;
if (isColorDrawable) {
cs = sPreloadedColorDrawables.get(key);
} else {
cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
}
Drawable dr;
boolean needsNewDrawableAfterCache = false;
if (cs != null) {
dr = cs.newDrawable(wrapper);
} else if (isColorDrawable) {
dr = new ColorDrawable(value.data);
} else {
dr = loadDrawableForCookie(wrapper, value, id, density, null);
}
// DrawableContainer' constant state has drawables instances. In order to leave the
// constant state intact in the cache, we need to create a new DrawableContainer after
// added to cache.
if (dr instanceof DrawableContainer) {
needsNewDrawableAfterCache = true;
}
// Determine if the drawable has unresolved theme attributes. If it
// does, we'll need to apply a theme and store it in a theme-specific
// cache.
final boolean canApplyTheme = dr != null && dr.canApplyTheme();
if (canApplyTheme && theme != null) {
dr = dr.mutate();
dr.applyTheme(theme);
dr.clearMutated();
}
// If we were able to obtain a drawable, store it in the appropriate
// cache: preload, not themed, null theme, or theme-specific. Don't
// pollute the cache with drawables loaded from a foreign density.
if (dr != null) {
dr.setChangingConfigurations(value.changingConfigurations);
if (useCache) {
cacheDrawable(value, isColorDrawable, caches, theme, canApplyTheme, key, dr);
if (needsNewDrawableAfterCache) {
Drawable.ConstantState state = dr.getConstantState();
if (state != null) {
dr = state.newDrawable(wrapper);
}
}
}
}
return dr;
} catch (Exception e) {
// 。。。
}
}

代码挺长的,总结一下,主要是这样的逻辑:

  1. 尝试从cache里取缓存,取到了的话就直接返回了;
  2. 如果没取到缓存,则加载资源并创建drawable;
  3. 把刚创建的drawable写入cache中;

到此我们可以断定,drawable的确实有缓存的了。
这里我们有必要先介绍下BitmapState这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
final static class BitmapState extends ConstantState {
// ...
Bitmap mBitmap = null;
// ...
BitmapState(Bitmap bitmap) {
mBitmap = bitmap;
// ...
}
BitmapState(BitmapState bitmapState) {
mBitmap = bitmapState.mBitmap;
// ...
}
public Drawable newDrawable() {
return new BitmapDrawable(this, null);
}
public Drawable newDrawable(Resources res) {
return new BitmapDrawable(this, res);
}
// ...
}

BitmapState是BitmapDrawable的内部类,他内部持有了bitmap对象,所以其实对于BitmapDrawable的各种配置本质上是由其内部的BitmapState对象来间接完成的。

再来看cache里存储的是啥?
这里的cache是DrawableCache类,从cache里取缓存数据用的是getInstance方法:

1
2
3
4
5
6
7
public Drawable getInstance(long key, Resources resources, Resources.Theme theme) {
final Drawable.ConstantState entry = get(key, theme);
if (entry != null) {
return entry.newDrawable(resources, theme);
}
return null;
}

其实这里缓存的就是BitmapState对象了。
最后,相对应的存缓存调用的是ResourceImpl类的cacheDrawable方法,这里就不再详细介绍了。

问题3

看到这里,相信大家对drawable的缓存以及ImageView的drawable作用机制已经有了大致的了解了。我们来看最后一个问题:怎么样可以改变单个drawable的透明度而不影响全局?
答案是使用Drawable类的mutate方法:

1
2
3
4
5
6
7
8
@Override
public Drawable mutate() {
if (!mMutated && super.mutate() == this) {
mBitmapState = new BitmapState(mBitmapState);
mMutated = true;
}
return this;
}

mutate内部通过构造方法copy了一个新的mBitmapState对象,是这么操作的:

1
2
3
4
5
6
BitmapState(BitmapState bitmapState) {
mBitmap = bitmapState.mBitmap;
...
mPaint = new Paint(bitmapState.mPaint);
...
}

这下明白了:mBitmap还是那个mBitmap,这个没变,只是构建了新的mPaint对象,copy了老的配置。我们对bitmap做的配置本质上是通过配置Paint来绘制出来的。

所以正确的代码是这样的:

1
mIvImg.getDrawable().mutate().setAlpha(128);

然后我们来过一遍代码执行流程:
mutate方法上面已经看过了,接下来是setAlpha方法:

1
2
3
4
5
6
7
8
@Override
public void setAlpha(int alpha) {
final int oldAlpha = mBitmapState.mPaint.getAlpha();
if (alpha != oldAlpha) {
mBitmapState.mPaint.setAlpha(alpha);
invalidateSelf();
}
}

嗯,配置了新的Paint的alpha值,继续:

1
2
3
4
5
6
public void invalidateSelf() {
final Callback callback = getCallback();
if (callback != null) {
callback.invalidateDrawable(this);
}
}

还记得在问题1里面我们卖的关子吗?就是这个Callback。ImageView在设置drawable的时候通过drawable.setCallback(this)把自己注册到了drawable中。这么做的目的就是当drawable发生变化需要刷新时,可以回调ImageView的刷新方法,这样就能实现ImageView的及时更新了。这里刷新是通过ImageView的onDraw来做的,内部调用了drawable的draw方法,一起来看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public void draw(Canvas canvas) {
final Bitmap bitmap = mBitmapState.mBitmap;
if (bitmap == null) {
return;
}
final BitmapState state = mBitmapState;
final Paint paint = state.mPaint;
...
if (shader == null) {
...
canvas.drawBitmap(bitmap, null, mDstRect, paint);
...
} else {
...
canvas.drawRect(mDstRect, paint);
}
}

代码很简单,取到BitmapState中的paint,然后通过canvas把bitmap重新绘制一把,让paint配置生效。

结语

遇到问题不用慌,翻源码,翻源码,翻源码。

贝店首页滑动体验优化实践

背景

贝贝的各个业务线APP中,常常会出现类似下面结构的页面:
列表模板
整个页面主要由头部信息区域、tab区域、列表内容区域组成。当向上滑动,头部内容滑出屏幕后,tab栏会吸顶,列表内容可以继续滑动。要实现这个页面结构,贝贝目前已有成熟的方案。
贝店首页也有这样的类似需求。用贝贝的成熟方案很容易实现,但是存在一个问题:
列表模板
当向上滑动页面,tab栏到达屏幕顶部的临界状态时,之后列表内容就不能继续向上滑动,需要松开手指后才能继续进行滑动操作。
为了更好的用户体验,我们决定解决这个问题,实现无阻断的流畅滑动体验。

需求分析

从贝店的具体需求出发,与贝贝首页相比,发现有些差异:贝店的tab栏是一个横向混动的时间轴,tab栏下方的列表区域是单个列表,而不是可以左右滑动的ViewPager。时间轴内选中一个时间点tab,下面的列表内容会同步刷新为对应数据。
基于此,我们选用RecyclerView来呈现列表数据,由于用不到ViewPager,我们对BeiBeiSDK中的公共组件PagerSlidingTabStrip进行了改造,剔除了其中的ViewPager相关逻辑,变得更轻量,产生了自定义的时间轴控件PagerSlidingTimeSlotTabStrip。
最后,要实现无阻断滑动,首先想到的是从事件分发的角度来处理滑动冲突问题,但这个方案导致的页面结构比较复杂,同时也需要自定义相关控件来处理Touch事件,比较繁琐。在当时贝店版本飞速迭代的情况下,我们采用了比较讨巧的方案:
整个列表包括头部内容区域只用一个RecyclerView来实现,为了实现时间轴吸顶效果,同时存在两个时间轴控件A和B,A位于HeaderView内,B悬浮在RecyclerView上方,默认不可见。他们的状态完全同步,如下图:
列表模板

详细实现

来看下时间轴控件PagerSlidingTimeSlotTabStrip:

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
public class PagerSlidingTimeSlotTabStrip extends HorizontalScrollView {
// 略去一坨代码...
private OnTabSelectedListener mSelectedListener;
// 与当前控件完全同步的控件
private PagerSlidingTimeSlotTabStrip mAttachedTabStrip;
public interface OnTabSelectedListener {
void onNewTabSelected(int curPosition);
}
public void setTabSelectedListener(OnTabSelectedListener listener) {
mSelectedListener = listener;
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.setTabSelectedListener(listener);
}
}
// 这个bool是为了控制当前控件与attached的控件之间不触发死循环,因为两者的联动是需要互相attach的,有些操作会不断相互调用,出现死循环
private boolean mIsOnlyAttachScroll = false;
public void setAttachedTabStrip(PagerSlidingTimeSlotTabStrip tabStrip) {
setAttachedTabStrip(tabStrip, false);
}
public void setAttachedTabStrip(PagerSlidingTimeSlotTabStrip tabStrip, boolean isOnlyAttachScroll) {
mAttachedTabStrip = tabStrip;
mIsOnlyAttachScroll = isOnlyAttachScroll;
}
// 刷新数据
public void refreshData(List<TimeSlotModel> list) {
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.refreshData(list);
}
// 刷新时间轴数据操作,略去
}
// 滚动到position位置的tab
public void scrollToChild(int position, int offset) {
if (mAttachedTabStrip != null && !mIsOnlyAttachScroll) {
mAttachedTabStrip.scrollToChild(position, offset);
}
// 具体滚动逻辑,略去
}
// 略去一坨代码...
@Override
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
super.onScrollChanged(x, y, oldX, oldY);
if (mOnScrollChangeListener != null) {
// 外部设置的监听
mOnScrollChangeListener.onScrollChanged(this, x, y, oldX, oldY);
}
if (mAttachedTabStrip != null) {
// 联动滚动,不区分主动与被动,直接同步
mAttachedTabStrip.scrollTo(x, y);
}
}
}

之前提到,我们有两个一毛一样的时间轴控件,当到达吸顶临界状态的时候控制他们是否可见,因此重点在于如何处理两个时间轴的状态完全同步。这里通过调用setAttachedTabStrip方法,让时间轴A内部持有时间轴B的对象引用,这样一来,A发生任何滚动、时间项选中等等UI变化时,都可以及时的应用到B上,这样就保证了两者的同步。
这里需要特别指出的是,两个时间轴是有主动和被动的角色区分的:

1
2
3
// 互相attach
mTimeSlotTabStrip.setAttachedTabStrip(mStickyTimeSlotTabStrip);
mStickyTimeSlotTabStrip.setAttachedTabStrip(mTimeSlotTabStrip, true);

在列表的HeaderView内的时间轴A为主动角色,悬浮状态的时间轴B为被动角色。
因为两者互相attach,部分操作可能会导致死循环,所以只在操作A时同步给B。这样不会有问题,因为任意时刻,A和B只会有一个为可见状态,我们只能看到当前所操作的那个时间轴。
可能有人会有疑惑:既然操作时只把A的状态同步给B,那么AB的可见状态切换时,如何保证两者看起来一样呢?
看上面代码,PagerSlidingTimeSlotTabStrip的onScrollChanged滚动监听回调方法里,会去处理滚动同步,这里不会区分主被动角色。

关于时间轴控件的visible切换控制,是通过监听RecyclerView的滚动来实现的:

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
// 监听RecyclerView的滚动
private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener
= new RecyclerView.OnScrollListener() {
...
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
// 列表中的时间档顶部到列表顶部的距离
int headerViewHeight = mHeaderViewContainer.getHeight() - mTimeSlotTabStrip.getHeight();
if (getScrollYDistance() > headerViewHeight) {
setStickyTimeSlotTabStropVisible(View.VISIBLE);
// 执行顶部tab隐藏动画
EventBus.getDefault().post(new TopbarAnimEvent(TopbarAnimEvent.TYPE_HIDE));
} else {
setStickyTimeSlotTabStropVisible(View.GONE);
EventBus.getDefault().post(new TopbarAnimEvent(TopbarAnimEvent.TYPE_SHOW));
}
}
private void setStickyTimeSlotTabStropVisible(int visible) {
if (visible == View.VISIBLE) {
// 同步两个时间档的当前滚动位置,只需要在mStickyTimeSlotTabStrip第一次展示的时候同步即可
mStickyTimeSlotTabStrip.scrollToChild(getCurrentTimeSlotIndex(), 0);
}
mStickyTimeSlotTabStrip.setVisibility(visible);
mStickyTimeSlotDividerLine.setVisibility(visible);
}
};

比较简单,这里不再赘述。
到此为止,核心的实现逻辑基本介绍完了,重点就在于如何控制两个时间轴控件的状态同步,让他们在visible切换后仍然看起来一样,实现无缝衔接。

总结

以上就是贝店首页无阻断流畅滑动体验的实践。在贝店业务快速迭代的背景下,我们需要用最快的方案实现需求,为了尽可能复用目前已有的组件,采用最小改动的方案来实现,以支撑业务飞速发展。

自定义View系列:打造一个显示密码等级的控件

0. 前言

最近项目不是特别紧张,有一点时间可以用来看点东西,于是找了下自定义View相关的文章来看看,毕竟也确实挺久没碰到了。和一基友交流了下,他最近也在看这方面,并且推荐了启舰的系列文章,大致都看了下,感觉讲的很细致,而且也挺全面,这里也推荐下,希望给大家在自定义View这块有所帮助。(传送门在文末会给出)

看完文章之后,得来一发才行呀,所谓“纸上得来终觉浅,绝知此事要躬行”嘛。想到了以前项目里有个显示密码等级的控件,那干脆就再来实现一下。

1. 需求

在注册界面,用户设置密码时,为了更好的交互体验,需要根据当前用户输入的密码的复杂程度,通过不同颜色的色块来实时的表示出密码有多强。好吧,说起来挺啰嗦,咱们直接看效果就知道了:
动图显示控件的效果
从效果图我们可以看出:

  • 控件总共有4个色块,分别为红、黄、蓝、绿,对应密码强度为风险、弱、中、强,而且色块后会有相应的强度描述,看起来好像是竖直方向居中的呢;
  • 用户输入的过程中,根据输入密码的复杂度,控件会实时更新成对应的状态;
  • 色块区域和文字之间貌似有一定间隙;

以上大致就是我们想要实现的东西了,接下来我们分析下具体实现。

2. 实现过程

2.1 自定义属性及解析

首先,一般自定义View都会涉及到自定义属性以及对应的解析取值操作,那么我们就来走一遍这个过程。我们定义一个属性,表示色块与文字之间的距离(当然其实定义文字尺寸貌似更好,这个就不用太过纠结了哈,都一样)。

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="PasswordLevelView">
<attr name="text_padding_level" format="dimension"/>
</declare-styleable>
</resources>

如你所见,属性定义就是这么简单。通过declare-styleable标签来定义了一个名为PasswordLevelView的属性集,通过attr标签来定义了一个名为text_padding_level的属性,值类型为dimension。

接下来我们要在xml布局里把刚定义好的属性用起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/edt_password"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:hint="please set password"
android:visibility="visible"/>
<com.example.jiangfei.passwordlevelview.widget.PasswordLevelView
android:id="@+id/pswd_level_view"
android:layout_width="160dp"
android:layout_height="16dp"
android:padding="4dp"
app:text_padding_level="8dp"/>
</LinearLayout>

好了,下面就该解析了:

1
2
3
4
5
6
7
public PasswordLevelView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 从xml读取自定义属性
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.PasswordLevelView, defStyleAttr, 0);
mPaddingText = typedArray.getDimensionPixelSize(R.styleable.PasswordLevelView_text_padding_level, mPaddingText);
calculateTextWidth();
}

也没啥好说的,主要就是两个重要方法:obtainStyledAttributes与TypedArray.getDimensionPixelSize。如果定义的是其他数据类型的属性的话,通过相应的TypedArray.getXXX方法去拿就好了。

2.2 类实现

我们这里的需求比较简单,核心思路就是在合适的时机,在正确的位置,把色块和文字给画出来,这个操作在onDraw方法中通过Canvas就能实现,所以我们继承View就行。来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/*
* 显示密码的强度等级的控件。
* Created by jiangfei on 2016/10/21.
*/
public class PasswordLevelView extends View {
// 文字尺寸
private float mTextWidth;
private float mTextHeight;
// 文字和图形的间距
private int mPaddingText = 0;
// 文字大小
private float mTextSize = 36F;
// 当前密级强度
private Level mCurLevel;
// 默认情况下的密级颜色
private int defaultColor = Color.argb(255, 220, 220, 220);
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public enum Level {
DANGER("风险", Color.RED, 0), LOW("弱", Color.YELLOW, 1), MID("中", Color.BLUE, 2), STRONG("强", Color.GREEN, 3);
String mStrLevel;
int mLevelResColor;
int mIndex;
Level(String levelText, int levelResColor, int index) {
mStrLevel = levelText;
mLevelResColor = levelResColor;
mIndex = index;
}
}
public PasswordLevelView(Context context) {
this(context, null);
}
public PasswordLevelView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PasswordLevelView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 从xml读取自定义属性
TypedArray typedArray =
context.getTheme().obtainStyledAttributes(attrs, R.styleable.PasswordLevelView, defStyleAttr, 0);
mPaddingText = typedArray.getDimensionPixelSize(R.styleable.PasswordLevelView_text_padding_level, mPaddingText);
calculateTextWidth();
}
private void calculateTextWidth() {
// 测量文字宽高,这里最多就2个字:风险
mPaint.setTextSize(mTextSize);
Rect rect = new Rect();
mPaint.getTextBounds(Level.DANGER.mStrLevel, 0, Level.DANGER.mStrLevel.length(), rect);
mTextWidth = rect.width();
mTextHeight = rect.height();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int measuredWidth;
int measuredHeight;
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
measuredWidth = widthMode == MeasureSpec.EXACTLY ? widthSize : getMeasuredWidth();
measuredHeight = heightMode == MeasureSpec.EXACTLY ? heightSize : getMeasuredHeight();
// 处理padding设置异常时的控件高度
if (measuredHeight < getPaddingTop() + getPaddingBottom() + mTextHeight) {
measuredHeight = (int) (getPaddingTop() + getPaddingBottom() + mTextHeight);
}
// 固定套路,保存控件宽高值
setMeasuredDimension(measuredWidth, measuredHeight);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//计算密级色块区域的宽高
float levelAreaWidth =
getWidth() - getPaddingLeft() - getPaddingRight() - mPaddingText - mTextWidth;
int levelNum = Level.values().length;
float eachLevelWidth = levelAreaWidth / levelNum;
float eachLevelHeight = getHeight() - getPaddingTop() - getPaddingBottom();
int startIndexOfDefaultColor = mCurLevel == null ? 0 : mCurLevel.mIndex + 1;
float startRectLeft = getPaddingLeft();
// 画密级色块
for (int i = 0; i < levelNum; i++) {
if (i >= startIndexOfDefaultColor) {
mPaint.setColor(defaultColor);
} else {
mPaint.setColor(Level.values()[i].mLevelResColor);
}
canvas.drawRect(
startRectLeft,
getPaddingTop(),
startRectLeft + eachLevelWidth,
getPaddingTop() + eachLevelHeight,
mPaint);
startRectLeft += eachLevelWidth;
}
// 画色块后面的字
mPaint.setColor(Color.BLACK);
mPaint.setTextSize(mTextSize);
String strText = mCurLevel != null ? mCurLevel.mStrLevel : "";
// 计算text的baseline
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
// baseline思路:先设为控件的水平中心线,再调整到文本区域的水平中心线上
// 注意:fontMetrics的top/bottom/ascent/descent属性值,是基于baseline为原点的,上方为负值,下方为正!
float baseLine =
getPaddingTop()
+ eachLevelHeight / 2
+ ((Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent)) / 2 - Math.abs(fontMetrics.descent));
canvas.drawText(
strText,
startRectLeft + mPaddingText,
baseLine,
mPaint);
// 最后,画一条水平中心线,看看文字的居中效果
// float centerVerticalY = getPaddingTop() + eachLevelHeight / 2F;
// canvas.drawLine(0F, centerVerticalY, getWidth(), centerVerticalY, mPaint);
}
/**
* 显示level对应等级的色块
*
* @param level 密码密级
*/
public void showLevel(Level level) {
mCurLevel = level;
invalidate();
}
}

代码不多,一点一点来过吧。
首先是定义了一个枚举Level,封装了对应的4个强度等级的信息。
接下来是3个构造方法,通过this调用,来执行参数最多的那个构造方法,这个是一般套路了,这里不再展开讲。
calculateTextWidth方法主要作用是计算强度文字的宽高尺寸,因为我们后面draw的时候需要各种计算尺寸。这里需要特别强调的是,在为文字测量宽高之前,需要先setTextSize,否则尺寸会不准确。这个也很好理解嘛,文字的大小不一样,当然所占用的宽高是不一样的。
接下来是onMeasure方法,也是一般的套路,通过MeasureSpec.getMode和MeasureSpec.getSize方法,来解析并调整控件的宽高,这里我们处理了高度值可能出现的异常情况。
然后是onDraw方法,在这里面我们draw了色块和文字。画色块的逻辑并不复杂,计算色块的宽高时,注意要把整体控件的padding值给考虑进去就行了。我们重点来看画文字的操作。
先来看一张图,不是本人原创哈:
FontMetric
在屏幕上展示的文字,不管尺寸颜色,都有上图中这几个重要的值:baseline, ascent, descent,他们分别表示的是哪一段长度,图上表示的很清除了,不再赘述。这些字段可以通过Paint.FontMetrics类来获取,而Paint.FontMetrics可以通过这个方法来取到:

1
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();

同样也要注意,在此之前需要先设置文字尺寸。
不知看到这里,大家有没有想过这些值的正负。我在调试的过程中截了个图:

尼玛啊,真是日了狗。。。API文档里说好的表示的是distance的呢?怎么会有正负值?事实上,我在画文字的过程中就掉这个坑里了,最后通过debug发现了这个坑,所以大家要特别注意,不要再掉里面啦。
好了,这几个字段说了这么多,到底有啥用呢?其实是为了drawText的时候计算baseline的坐标服务的。使用Canvas.drawText方法可以画文字,其中有两个参数就是文字的baseline坐标。所以为了把文字draw到色块横向对应的中心线上,我们需要计算出文字的baseline坐标:

1
2
3
// baseline思路:先设为控件的水平中心线,再调整到文本区域的水平中心线上
// 注意:fontMetrics的top/bottom/ascent/descent属性值,是基于baseline为原点的,上方为负值,下方为正!
float baseLine = getPaddingTop() + eachLevelHeight / 2 + ((Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent)) / 2 - Math.abs(fontMetrics.descent));

OK,比较难的baseline坐标值就搞定了。到此,核心的东西就都介绍完了。

3. 总结

本文实现了一个简单的密码等级展示效果,目的是回顾一下自定义View的过程。在我看来,自定义View的关键是要理解并掌握Canvas,Paint,Matrix这几个核心的绘制相关类,还有就是各种动画。掌握好这些,要实现一些复杂炫酷的效果就不是困难的事情啦。

最后,我是demo下载地址

4. 参考资料

1.Android自定义控件三部曲文章索引

2.Android字符串进阶之三:字体属性及测量(FontMetrics)

为RecyclerView设置divider的正确姿势

1. 关于RecyclerView的divider

ListView的divider设置起来很简单,只需要在xml中使用divider标签即可轻松实现。相比之下,RecyclerView就麻烦一点了,基本步骤如下:

  • 继承RecyclerView.ItemDecoration,复写onDraw()/onDrawOver()、getItemOffsets方法;
  • 通过mRecyclerView.addItemDecoration方法,给到RecyclerView来使用;

其中,自定义RecyclerView.ItemDecoration类,主要的套路就是在onDraw/onDrawOver方法中,通过Canvas去绘制divider,可以是Rect也可以是drawable,只要Canvas能draw出来都行。这一点相比ListView来讲,可定制性大大提高,可以满足绝大部分的需求了。

关于ItemDecoration中onDraw/onDrawOver方法,需要注意的是他们的执行顺序,这一点可以看下方法注释,介绍的很清楚了:
ItemDecoration.onDraw -> itemView的onDraw -> ItemDecoration.onDrawOver

2.举个简单的栗子

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
public DrawableItemDecoration(Context context) {
mContext = context;
mPaint = new Paint();
mPaint.setColor(0x99FF0000);
mDividerDrawable = mContext.getResources().getDrawable(R.drawable.arrow_right);
}
@Override
public void onDraw(Canvas canvas, RecyclerView parent, RecyclerView.State state) {
final int top =
(parent.getMeasuredHeight() - mDividerDrawable.getIntrinsicHeight()) / 2;
final int bottom = top + mDividerDrawable.getIntrinsicHeight();
final int childSize = parent.getChildCount();
for (int i = 0; i < childSize - 1; i++) {
final View child = parent.getChildAt(i);
RecyclerView.LayoutParams layoutParams =
(RecyclerView.LayoutParams) child.getLayoutParams();
final int left = child.getRight() + layoutParams.rightMargin + 10;
final int right = left + dividerWidth - 20;
mDividerDrawable.setBounds(left, top, right, bottom);
mDividerDrawable.draw(canvas);
}
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
}
@Override
public void getItemOffsets(
Rect outRect, View view,
RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
outRect.set(0, 0, dividerWidth, 0);
}

可以看到在onDraw方法中,在横向排列RecyclerView的每个item后面,通过Canvas画出了一个drawable来作为divider(这个drawable是一个右向的箭头,类似右尖括号的效果)。这里需要注意的一点是,getItemOffsets方法中需要设置item之间的间隔,否则画出来的divider会覆盖在itemView之上。

完~

很惭愧<br><br>只做了一点微小的工作<br>谢谢大家