Android LayoutTransition动画使用时发生crash原因分析 [Android设计缺陷]

579 查看

周末宅家好不容易实现了个比较赞的交互动画,然而运行到不同版本的手机上竟然会遇到奇怪的crash问题。

为什么说奇怪呢,因为使用了16的api,在16(4.2.1)上运行正常毫无疑问,理所当然我在代码了里加上了判断,14以下不使用该api,可是运行到14(4.0.4)的手机上时还是crash了。 这个api就是 LayoutTransitionenableTransitionTypedisableTransitionType,气愤无比,这都能crash,还有没有原则了!遂决定找出原因,难道android系统设计真的像我想象的那么烂吗?虽然喷过很多次了,这次一定要找出凶手!

首先还原现场,整个动画的大致效果是这样的:

LinearLayout1:
---------------------
| 1 | 2 | 3 | 4 | 5 |
---------------------

LinearLayout2:
-------------------------------
| 1-1 | 2-1 | 3-1 | 4-1 | 5-1 |
-------------------------------

界面元素:一上一下2个 LinearLayout,分别有5个child。

交互动作:可以随意将1~5号拖到下面一个布局中(drag and flinging),反之也可以把下面的child拖到上面的布局中。

动画效果:当1号拖出1号区域时,2~5向左滑动填充空白空间,重新拖回1号区域时,2~5则向右滑动以腾出空间。快速向1-1方向拖动1号然后松手扔掉,速度足够则1号飞向1-1区域,1-1~5-1向右滑动以腾出空间,速度不够则返回1号原始位置。

动画实现关键点:1号移出1号区域,重新进入1号区域,以及进入1-1区域,其余child的挤占和腾挪空间动画效果。

实现思路 1

使用ValueAnimator,对1号view的layout_width属性进行动画。 要求:api level 11,实现细节比较复杂,尝试了下但是马上发现了新大陆,就立即暂停了。

实现思路 2

惊喜的发现LayoutTransition可以通过简单的设置view的Visibility属性转换即可完成上述动画效果,开始并没有考虑api level的问题,采用优先实现的原则,立马动手,调试了N久终于完成了整个动画。

接下来分析下动画实现过程:

  • 针对1号,点击:Visible->Invisible(没有动画,1号占空间),立即render一个镜像,添加到最顶层容器里用于拖动过程和动画。
  • 移除1号区域:Invisible->Gone(不占空间,2~5有动画)
  • 移回1号区域:Gone->Invisible(占空间,2~5有动画)

最重要的来了,为了完成上述动画,必须合适配置 LayoutTransition 的几个参数。

public static final int CHANGE_APPEARING = 0;
public static final int CHANGE_DISAPPEARING = 1;
public static final int APPEARING = 2;
public static final int DISAPPEARING = 3;
public static final int CHANGING = 4;

CHANGE_APPEARING和CHANGE_DISAPPEARING 指的是2~5的动画效果,即1号的加入导致被影响的其余child做的动画,默认是移动,即被挤开。

APPEARINGDISAPPEARING指的是1号的的add和remove时的动画效果(visibility属性变为Visible和Gone,以及addView和removeView都是这个效果),默认是淡入淡出。

CHANGING指除去add和remove的其余动画效果,默认是disabled的。(如Invisible和Visible,Gone之间的转换)

代码配置如下:

if (Build.VERSION.SDK_INT >= 11) {
    LayoutTransition lt = new LayoutTransition();
    if (Build.VERSION.SDK_INT >= 16) {
        lt.disableTransitionType(LayoutTransition.APPEARING);
        lt.enableTransitionType(LayoutTransition.CHANGING);
        lt.disableTransitionType(LayoutTransition.DISAPPEARING);
    }
    viewGroup.setLayoutTransition(lt);
}

我们想要的最好的效果就是这样配置的,去掉APPEARING和DISAPPEARING时的淡入淡出动画,“移除”和“移回”时根本不需要这个动画(我们有镜像了,有动画就凌乱了 -_- ! )。开打CHANGING动画(我们需要在Invisible和Gone,Visible之间切换),否则“移回”时没有过渡动画,是生硬的突变效果。

问题来了:enable和disable的api level要求16,小郁闷了一把,也还好,那就只有牺牲11的体验了,美其名曰体验降级吧,把这两个开关干掉,就让他丑一点,好歹能工作。 悻悻的在4.0的手机上一跑,轻轻地拖出1号朝对面一扔... Oops,something shit happens!

07-21 23:35:42.568  15169-15169/com.taobao.tao          
E/AndroidRuntime: FATAL EXCEPTION: main
    java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
    at android.view.ViewGroup.addViewInner(ViewGroup.java:3347)
    at android.view.ViewGroup.addView(ViewGroup.java:3218)
    at android.view.ViewGroup.addView(ViewGroup.java:3175)

crash的地方在放手的ACTION_UP事件的处理中,这个时候一旦判定了该view将会被转移到另一个LinearLayout中,我做的事情很简单,remove and reAdd

ViewUtil.safeRemoveChildView(mLinearLayout, downView);
setDownViewVisibility(downView, View.INVISIBLE);
ViewUtil.safeAddChildView(mTargetLinearLayout, downView, toPosition);

首先移除该view,然后巧妙的将visibility属性设置为View.INVISIBLE,最后加到目的地LinearLayout中,这个时候目的地LinearLaout会有个腾挪空间的动画发生,就好像预先知道这个view将要飞过去,我们会在飞过去的动画完成后还原为Visible,同时移出镜像,看起来是不是天衣无缝?

根据crash log的提示,我们加上log,在运行一遍:

07-22 00:10:02.848  15444-15444/com.taobao.tao
D/LOG_TAG: will safeRemoveChildView : remove view = android.widget.TextView@41b305a0 parent = android.widget.LinearLayout@41b2f040 realParent = android.widget.LinearLayout@41b2f040
07-22 00:10:02.848  15444-15444/com.taobao.tao
D/LOG_TAG: did safeRemoveChildView : remove view = android.widget.TextView@41b305a0 parent = android.widget.LinearLayout@41b2f040 realParent = android.widget.LinearLayout@41b2f040

问题了然,根本就没有remove掉这个child,纳尼?你™在逗我?
初步猜想,我用的都是11+的api,没有使用任何14+新接口,14上面居然crash。
可能的原因1:14相对于11的实现源码有变更,改出bug了?(...lol...)或者这个问题一直存在?可能的原因2:16相对于14,fix了这个bug?

第二个可能性更大,最后的办法,从android源码找证据!
对比14和16,直接从removeView看进去 removeView -> removeViewInternal -> removeFromArray,这里有关键代码:

if (!(mTransitioningViews != null && mTransitioningViews.contains(children[index]))) {
    children[index].mParent = null;
}

继续跟踪 mTransitioningViews,只要找到是谁把view塞到里面去的地方应该就可以了。

This method tells the ViewGroup that the given View object, which should have this ViewGroup as its parent, should be kept around (re-displayed when the ViewGroup draws its children) even if it is removed from its parent. This allows animations, such as those used byandroid.app.Fragment and android.animation.LayoutTransition to animate the removal of views. A call to this method should always be accompanied by a later call toendViewTransition(android.view.View), such as after an animation on the View has finished, so that the View finally gets removed.

Parameters:view The View object to be kept visible even if it gets removed from its parent.

public void More ...startViewTransition(View view) {
    if (view.mParent == this) {
        if (mTransitioningViews == null) {
            mTransitioningViews = new ArrayList<View>();
        }
        mTransitioningViews.add(view);
    }
}

再往上一级:可以确定是 LayoutTransition 引起的调用。

public void More ...startTransition(LayoutTransition transition, ViewGroup container,
        View view, int transitionType) {
    // We only care about disappearing items, since we need special logic to keep
    // those items visible after they've been 'removed'
    if (transitionType == LayoutTransition.DISAPPEARING) {
        startViewTransition(view);
    }
}

与之对应的是 endTransition,这两个方法是必须要调用的一对。

This method should always be called following an earlier call tostartViewTransition(android.view.View). The given View is finally removed from its parent and will no longer be displayed. Note that this method does not perform the functionality of removing a view from its parent; it just discontinues the display of a View that has previously been removed.

Returns: view The View object that has been removed but is being kept around in the visible hierarchy by an earlier call to startViewTransition(android.view.View).

public void More ...endViewTransition(View view) {
    if (mTransitioningViews != null) {
        mTransitioningViews.remove(view);
        final ArrayList<View> disappearingChildren = mDisappearingChildren;
        if (disappearingChildren != null && disappearingChildren.contains(view)) {
            disappearingChildren.remove(view);
            if (mVisibilityChangingChildren != null &&
                    mVisibilityChangingChildren.contains(view)) {
                mVisibilityChangingChildren.remove(view);
            } else {
                if (view.mAttachInfo != null) {
                    view.dispatchDetachedFromWindow();
                }
                if (view.mParent != null) {
                    view.mParent = null;
                }
            }
            mGroupFlags |= FLAG_INVALIDATE_REQUIRED;
        }
    }
}

费了好大劲终于搞清楚了,14和16代码差别很小,这一块逻辑基本没变。原因注释已经写得非常清楚了,DISAPPEARING的child压根就没有remove掉,在LayoutTransition动画完成之后才是真正的移除,由于采用了这种实现方式,所以这个bug在11~16上存在(源码几乎没变,16里面注释掉这两个开关,依旧crash,原因就是DISAPPEARING动画),而且极大的可能性在最新版本依旧存在!所以绕过这个bug的唯一方法就是在16+上面关闭DISAPPEAR这个开关。

悖论来了:本想在11+上牺牲体验,接受那两个难看效果的存在,美其名曰体验降级,现在看来这个可怜的愿望却变成了奢望,<16根本就没有关闭的开关,这还咋用?!回头再继续去寻求<16不crash的workaround方法,另外11+的动画适配可以考虑思路3(<11的还没着落呢,泪),这尼玛android的开发和适配成本是有多高?直接绝望到吐血的有木有? 一份代码开发几个版本?

实现思路 3

和2比较类似,使用反复add和reAdd child代替visible变化,采用过这个方案(visible效果没调好之前),有个好处就是可以解决掉11+的Invisible切换生硬问题,之后未采用淘汰了。

总结

批评:这个属于典型的功能性封装改变了类的表现行为,违背一致性设计原则。在这个场景下removeView这个方法行为发生改变,可能导致不可预期的后续问题。

解决办法可以考虑在removeView之前render一个镜像用于做动画,不需要持有这个view,android系统这个设计也许是考虑到性能的原因?(render一个未知的view消耗的性能对整个系统来说不确性比较大?有可能,如果太耗时动画就可能卡顿一下)但总比目前这个方案好多了吧。