[一千个Bug] | API设计之殇——静态方法的隐含状态系统
- 本文约2300字,建议阅读时间10分钟
- 阅读需要的知识背景:Android应用开发
- 今天分享的这个bug普适性不强,似乎缺少总结的必要,文猫君力求从中挖掘有价值的点分享给大家。如果读完第一节问题描述觉得没有营养,读者们可以不必继续往下阅读以节约宝贵的时间和注意力。
一千个的目标立的有点大,不过我不打算更改这个数字,重点在于它提醒自己坚持记录和分享,并从中获益。
问题描述
起因是我封装了一个方便为某个UI元素执行入场、出场动画逻辑的工具类,名叫<code>AnimationWrapper</code>,它的代码大致如下:
public class AnimationWrapper {
/** 视图可见性状态,始终可见 */
public static final int ANIM_VIEW_STATE__VISIBLE = 0;
/** 视图可见性状态,从可见变为不可见 */
public static final int ANIM_VIEW_STATE__VISIBLE_TO_INVISIBLE = 1;
/** 视图可见性状态,从不可见变为可见 */
public static final int ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE = 2;
/** 附加的动画监听器,用于在内建的AnimationListener的回调中追加业务逻辑 */
public interface ExtraAnimationListener {
/** 动画开始 */
void onAnimationStart();
/** 动画结束 */
void onAnimationEnd();
}
/**
* 执行动画
*
* @param view 执行动画的视图
* @param animResId 动画资源ID
* @param visibilityChangeType 可见性变化类型
* @param extraListener 附加的动画监听器
* @return 返回动画的duration
*/
public static long animate(final View view, int animResId, final int visibilityChangeType,
final ExtraAnimationListener extraListener) {
return animateWithDelay(view, animResId, visibilityChangeType, extraListener, 0);
}
/**
* 执行动画,可指定延时
*
* @param view 执行动画的视图
* @param animResId 动画资源ID
* @param visibilityChangeType 可见性变化类型
* @param extraListener 附加的动画监听器
* @param startWithDelay 动画启动延时
* @return 返回动画的duration
*/
public static long animateWithDelay(final View view, int animResId, final int visibilityChangeType,
final ExtraAnimationListener extraListener, long startWithDelay) {
if (view == null) {
return 0;
}
final Animation animation = AnimationUtils.loadAnimation(BaseApplication.getApplication(), animResId);
animation.setAnimationListener(new AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
if (extraListener != null) {
extraListener.onAnimationStart();
}
}
@Override
public void onAnimationRepeat(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
if (visibilityChangeType == ANIM_VIEW_STATE__VISIBLE_TO_INVISIBLE) {
view.setVisibility(View.INVISIBLE);
} else if (visibilityChangeType == ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE) {
view.setVisibility(View.VISIBLE);
}
if (extraListener != null) {
extraListener.onAnimationEnd();
}
}
});
Runnable animRunnable = new Runnable() {
@Override
public void run() {
if (visibilityChangeType == ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE
&& view.getVisibility() == View.VISIBLE) {
view.clearAnimation();
view.setVisibility(View.VISIBLE);
return;
} else if (visibilityChangeType == ANIM_VIEW_STATE__VISIBLE_TO_INVISIBLE
&& view.getVisibility() == View.INVISIBLE) {
view.clearAnimation();
view.setVisibility(View.INVISIBLE);
return;
}
view.startAnimation(animation);
}
};
if (startWithDelay <= 0) {
view.post(animRunnable);
} else {
view.postDelayed(animRunnable, startWithDelay);
}
return animation.getDuration();
}
/**
* 使用动画离开界面,可指定延时开始动画
*
* @param view 要离开的视图
* @param animResId 进入动画的资源ID
* @param delayBeforeStartAnim 开始动画前的延时
* @return 离场动画需要的总耗时(包括前置的延时), 以毫秒计
*/
public static long leaveWithAnimation(final View view, final int animResId, final long delayBeforeStartAnim);
/**
* 使用动画进入界面,可指定延时开始动画
*
* @param view 要进入的视图
* @param animResId 进入动画的资源ID
* @param delayBeforeStartAnim 开始动画前的延时
* @return 入场动画需要的总耗时(包括前置的延时),以毫秒计
*/
public static long enterWithAnimation(final View view, final int animResId, final long delayBeforeStartAnim);
简单的说,这个包装类其实是提供了一个调用完成指定目标视图,动画资源,动画延时,动画回调等逻辑的工具方法,目标是让你少写一些重复的代码。执行动画的方法的返回值是动画过程需要的耗时,这样设计的目的是为了能够让入场动画和离场动画之间可以建立时序上的协同。便利,可协同,这是我写这个类的两个意图。下面是一个使用这个工具类的例子:
/* 范例场景:点击一个item,动画展示出的它的名称,然后一段时间后名称自动消失 */
// 找到名称文本视图,设置文本内容为点击项的名称
TextView nameTextView = (TextView) findViewById(R.id.tv_show_item_name);
nameTextView.setText(clickedItem.getName());
// 渐显动画展示出这个文本
long fadeInTime = AnimationWrapper.animateWithDelay(nameTextView, R.anim.anim_fade_in,
AnimationWrapper.ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE, null, 0);
// 渐显完毕时,再持续展示1秒后再用渐隐动画隐藏掉这个文本
AnimationWrapper.animateWithDelay(nameTextView, R.anim.anim_fade_out,
AnimationWrapper.ANIM_VIEW_STATE__VISIBLE_TO_INVISIBLE, null, 1000L + fadeInTime);
问题是在我把这个工具用于一个<code>Seekbar</code>的<code>onProgressChanged</code>回调中使用时出现的。我的本意是让这个<code>Seekbar</code>在滑动滑块的过程中,能够响应每一次progress数值的变化,并以动画的方式将这个数值呈现给用户。读到这里,有经验的、积极思考的读者可能已经预测出我将会遇到的问题。没错,在这个场景下,<code>Seekbar</code>的progress数值展示动画没有迎合我想当然的预期。当你连续滑动<code>Seekbar</code>时,动画可能会出现闪烁现象。
回到代码中一探究竟,我们推断闪烁应该是显示和隐藏的<code>Runnable</code>交替执行的结果。原本的时序协同设计在<code>onProgressChanged</code>这种连续回调的场景中失效了。显然,我们不仅要考虑一显一隐的协同,还要考虑在一次显示和一次隐藏的配对中被穿插进新的显示和隐藏配对的情况。用符号表意这种情况大概可以这样描述:
show1 show2 show3 hide1 show4 hide2 hide3 show5 hide4 .. hide5 ..
show和hide连续穿插执行,难怪要闪了。
我们来改进一下,把上面的show/hide模式改成这样:
show-> {
cancel hideRunnable if it exists;
post showRunnable
}
hide-> {
post hideRunnable
}
用正经的java代码实现的新增API大概是长这个样子的:
public class ViewAnimationRunnable implements Runnable {
public View animView = null;
public int animResId = -1;
public int visibilityChangeType = AnimationWrapper.ANIM_VIEW_STATE__VISIBLE;
public AnimationWrapper.ExtraAnimationListener listener = null;
@Override
public synchronized void run() {
if (animView == null) {
return;
}
AnimationWrapper.animate(animView, animResId, visibilityChangeType, listener);
}
}
/**
* 延迟执行动画, 外部传递Runnable
*
* @param view 执行动画的视图
* @param animResId 动画资源ID
* @param visibilityChangeType 可见性变化类型
* @param listener 动画监听器
* @param appearRunnable 演示出现动画的Runnable
* @param disappearRunnable 演示消失动画的Runnable
* @param delay 动画延迟
* @return 返回动画的执行时间
*/
public static long animateWithDelay(final View view, int animResId, final int visibilityChangeType,
final ExtraAnimationListener listener, ViewAnimationRunnable appearRunnable, ViewAnimationRunnable disappearRunnable,
long delay) {
if (visibilityChangeType == ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE
|| visibilityChangeType == ANIM_VIEW_STATE__VISIBLE) {
if (appearRunnable == null) {
return 0;
}
// 既然现在要执行一个出现的动画任务,那么如果存在消失的动画任务还没做,自然可以取消掉,最后的消失依赖于外部
view.removeCallbacks(disappearRunnable);
appearRunnable.animView = view;
appearRunnable.animResId = animResId;
appearRunnable.listener = listener;
appearRunnable.visibilityChangeType = visibilityChangeType;
view.postDelayed(appearRunnable, delay);
} else {
if (disappearRunnable == null) {
return 0;
}
disappearRunnable.animView = view;
disappearRunnable.animResId = animResId;
disappearRunnable.listener = listener;
disappearRunnable.visibilityChangeType = visibilityChangeType;
view.postDelayed(disappearRunnable, delay);
}
Animation animation = AnimationUtils.loadAnimation(BaseApplication.getApplication(), animResId);
return animation.getDuration();
}
留意<code>view.removeCallbacks(disappearRunnable);</code>这行代码和它的注释,思考它有什么不合理性。
我们用新增的这个方法替换之前的版本,前面的用例变成了这样:
long fadeInTime = AnimationWrapper.animateWithDelay(nameTextView, R.anim.anim_fade_in,
AnimationWrapper.ANIM_VIEW_STATE__INVISIBLE_TO_VISIBLE, null,
appearRunnable, disappearRunnable, 0);
// 渐显完毕时,再持续展示1秒后再用渐隐动画隐藏掉这个文本
AnimationWrapper.animateWithDelay(nameTextView, R.anim.anim_fade_out,
AnimationWrapper.ANIM_VIEW_STATE__VISIBLE_TO_INVISIBLE, null,
appearRunnable, disappearRunnable, 1000L + fadeInTime);
重新验证一下,看起来似乎没问题了。在生产环节中,实际发现和解决这个问题的过程比本文描述的要复杂一下,时间也稍长一些。
真正的问题所在
直到本文临近结尾的时候,我忽然意识到问题的真正所在,于是我把文章的标题修改为“API设计之殇”——因为同类问题造成的痛苦似曾相识,我不止一次地遭遇过,我把它们统称为“静态方法的隐含状态系统”问题。当这个工具类的入场动画和出场动画出现协同问题时,我的第一反应是在API级别解决掉这个问题,这就可能在外部使用者不知情的情况下,引入额外的变量。这个变量使得两次工具方法的调用之间产生了一个隐含的约束条件,使得工具方法的使用方式不再那么“自由”。
一般来说,即使高度贯彻面向对象的设计思路,出于API设计原子化,执行效率等因素考虑,我们有时候不得不用面向过程的方式实现某个特定功能,这种情况下,API设计者提供给我们的是一组具有时序依赖,状态转化的关联API。但这通常会要求关联的API是通过一个对象实例来使用的。因为实例对象有生命周期,如果内部自有其状态系统,要求使用者遵照一定的用法,比如调用顺序,调用时机,都是相对容易理解的。而如果API是一个静态方法,我会倾向于把它设计为一个独立运作的系统,如若不然,使用者会很困惑,因为没有一个显性的生命周期能够确保他在正确的地点,正确时机使用这个方法。
弄清楚这个道理后,我们发现,去掉新增版本的API,仍然使用最初的版本,把入场动画放到<code>Seekbar</code>的<code>onStartTrackingTouch</code>,把出场动画放到<code>Seekbar</code>的<code>onStopTrackingTouch</code>才是海阔天空之的解决方案。因为何时何地使用工具,这本该是工具的使用者才需要考虑的事情,而工具本身只需要专注于解决一个特定的问题。