头条视频详情页新交互实现方案

背景

头条视频App在1.0.8版本中准备优化打开详情页的交互体验。目的是使打开详情页显得更轻,减少用户进入详情页的交互成本,提升详情页的打开率。

首先来看下新旧交互方式的对比(左为旧交互,右为新交互):

新旧交互的主要差别在,从列表页中播放视频切换到详情页播放视频的过程中,视频是不会暂停的,画面和声音都是完整连续的,只是在播放器的下方展开了一个详情页。视觉上打开详情页像是拉出了一个抽屉,关闭详情页是关闭了抽屉。

方案选型

1. Transition动画 实现

首先想到的是上述交互和Android中Transition动画非常类似,但是一番调研后发现Transition是有着诸多限制的:

  1. 不支持 SurfaceViewTextureView,具体原因可以参考:传送门
  2. 只支持Android 5.0以上系统;
  3. Fresco对Transition的支持也不是很友好(因为我们列表页图片加载使用的是Fresco,而DraweeView对共享元素动画的支持也不是很好:传送门)。

因此这个方案可行度不高。

2. Activity实现

这个方案的大致思路是:在启动详情页Activity的时候将列表页中播放器View “移花接木” 到详情页的Activity中。尝试一番后发现,TextureView在从父View detach后再重新attach到新的父View的过程中,会出现几帧的黑屏。原因很简单:

TextureView在detach后会销毁Surface,没有了Surface,自然也就无法正确渲染了。

当然其实也是有办法解决的,就是覆盖 TextureView#onDetachedFromWindowInternal 方法,在 “移花接木” 过程中不调用super。但是这样会影响view正常的生命周期,可能带来未知的风险。同时因为Activity启动会存在几百毫秒甚至几秒的延迟(卡顿的情况下),移花接木的时机也不太好把握。

因此此方案也不太靠谱。

3. Fragment实现

此方案类似于方案2,不同的是,Fragment实现就是以Fragment来呈现详情页,该Fragment依附于MainActivity来展现。此方案因为不需要启动新的Activity,因此上面两种方案存在的问题都能很好的解决。但是又存在一些新的问题:

  1. Fragment太重,而且Fragment本身槽点太多,bug也很多;
  2. Fragment在support包中,其扩展性较差,以及无法解决后文中提到的一些问题。

因此最终也没有选用此方案。

Page

经历了前面几种方案的失败后,发现Fragment的方案在交互效果上已经能比较完美的符合需求了,只是在后续维护和代码角度上,还存在一些问题无法优雅地解决。因此决定基于View来实现一套类Fragment的Page框架。

Page的设计

Page类似于Fragment,但是比Fragment更轻,同时脱离了复杂的FragmentManager和support包限制后,Page可定制程度相对更高。综合业务需求和Android特性,Page大致需要以下功能:

  1. 需要完整的生命周期;
  2. 支持对Touch,Key,Focus等系统事件的响应;
  3. 支持低成本地替换之前的Activity;
  4. 支持播放器无缝从列表切换到详情页中等...

Page生命周期

类似Fragment,Page显示同样需要依附于一个ViewGroup上,同时显示过程需要依赖系统触发的Activity生命周期的回调,Page的显示,销毁也需要触发宿主Activity对应生命周期变化。

  1. Page和Activity有着类似的生命周期,onCreate->onStart->onResume->onShow->onPause->onStop→onDestroy→onDismiss。只是在Activity基础的生命周期之上新增了onShow和onDismiss,类似于Dialog中的show和dismiss;
  2. Page内部会处理生命周期的触发逻辑,比如创建的时候触发onCreate,显示的时候触发onShow;
  3. Page支持生命周期监听,在其生命周期方法中会分发其生命周期事件到对应的生命周期监听器中;
  4. Page会监听Activity的生命周期,当Page在前台的时候,系统触发的Activity生命周期变化Page都会收到同样的生命周期回调。

Activity的生命周期处理

因为详情页不再是一个Activity,因此之前打开详情页触发MainActivity的生命周期都将失效,因此需要Page来触发生命周期。

引入ILifeCycleProvider,Page作为一个生命周期的提供者,具备生命周期分发功能,在MainActivity中监听Page的生命周期变化,当Page显示的时候,触发MainActivity的onPause,Page隐藏触发MainActivity的onResume。

因为业务方有很多基于生命周期的业务实现,比如一些数据统计等,因此Page完整的生命周期保证这些数据不会出现异常。总原则,保证Page显示会触发和启动Activity同样的生命周期。

Touch,Key,Focus等事件拦截

上面这些处理完后,Page能正常显示,但是发现显示后,back无法退出,点击时,下层的列表中的item会响应点击事件。因为我们的Page其实就是一个View,展示后最终事件分发还是依赖于宿主的Activity。因此我们需要拦截事件的分发。

以Touch事件为例,先简单看一下Touch事件分发的一个流程: 总结下就是:底层驱动 → ViewRootImpl → DecorView → Window.Callback → Activity → DecorView.super

其实就是在DecorView这一层增加了一个Window.Callback的调用,这样只要是带有Window的组件,只要设置了 Window.Callback 都有机会去拦截所有事件的分发。

那么我们作为一个依附于Activity的Page,显然和Activity共享了一个Window,Page如果想要拦截事件,那么就可以从这个Window.Callback为切入点,只要我们给Activity的Window设置了Page自定义的Window.Callback,那么事件自然就分发到Page里了,这样是否可行呢?

答案肯定是可行的,support-v7包中已经为我们给出了很好的示例,既然support包都这么用了,稳定性自然不是问题。示例如下:

这样,当Page显示的时候给Activity中Window原始的Window.Callback设置一个wrapper,在wrapper中处理我们需要处理的事件,如,key,touch,windowFocus,attachedToWindow,detachedFromWindow等。

当Page销毁的时候,将原始的Activity中Window的Window.Callback重新设置给Activity的Window,这样就能优雅的处理事件的分发了。

public Page(Activity base) {  
    super(base);
    Window.Callback wrapped = base.getWindow().getCallback();
    if (wrapped == null) {
        wrapped = base;
    }
    mWindowCallback = new WindowCallbackWrapper(wrapped) {
        @Override
        public boolean dispatchKeyEvent(KeyEvent event) {
            return Page.this.dispatchKeyEvent(event);
        }

        @Override
        public boolean dispatchTouchEvent(MotionEvent event) {
            return Page.this.dispatchTouchEvent(event);
        }
        // ...
    };
    // ...
}

Touch事件的偏移

经过上述事件的拦截后,Page能正确的响应所有的事件了,但是当点击按钮的时候发现点击的位置和实际响应的位置有偏差,这里就牵涉到Touch事件的坐标偏移了。

仔细研究Touch的分发栈可以看到,ViewGroup在给子View分发事件的时候是通过调用 ViewGroup#dispatchTransformedTouchEvent 来进行的,这里面其实就做了Touch的坐标偏移:

大致原理就是每个View收到的Touch事件在经过变换后的坐标,都是以view的左上角的顶点为原点的。ViewGroup在分发事件的时候需要将原始的Touch事件的x,y坐标转换为子View自己的坐标系。

回到刚才我们出问题的Page中,因为Page的rootView和原始Touch事件的坐标系不一样,因此直接使用该Touch事件进行分发是有问题的。

解决办法很简单,根据Page rootView在DecorView(DecorView是包含StatusBar和NavigationBar的,因此和原始的Touch事件的坐标系是一样的)中top和left值来对Touch事件进行一个偏移即可。

public boolean dispatchTouchEvent(MotionEvent event) {  
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        final View decorView = mActivity.getWindow().getDecorView();
        final int top = UIUtils.getTop(decorView, mRootView);
        final int left = UIUtils.getLeft(decorView, mRootView);
        mTouchOffsetX = decorView.getScrollX() - left;
        mTouchOffsetY = decorView.getScrollY() - top;
    }
    event.offsetLocation(mTouchOffsetX, mTouchOffsetY);
    return mRootView.dispatchTouchEvent(event);
}

经过以上步骤,Page能正常显示和使用,Page的基本功能也算是完成了。

Page的对外方法

下面是Page大致的对外方法:

注:Page#show(Pair params) 显示Page,参数是Intent和可扩展的Object类型的Pair组合,同时又增加了对不可序列化参数的传递。这样设计的目的是为了兼容旧的启动Activity的Intent,因此降低迁移成本。

自定义Context

当使用新的Page来迁移旧Activity的过程中,又遇到了新的问题。之前代码中很多使用:

int v = mContext instanceof ICompatActivity ? ((ICompatActivity) mContext).getXXX() : 0;  

存在诸多类似这种直接基于Context来判断的是否实现了某接口,然后调用接口的方法的写法。这个context通常都是在构造的时候传入的Activity的实例。

当然这种代码本身就存在问题,设计原则中有一条:组合优于继承。我们应该以内部变量的方式来构造一些如上所述的接口的实例,而不应该全部使用Activity来实现这些接口。但是代码中这种写法太多,全部修改一遍,成本太大,也不是本次优化的重点,性价比不高。因此得想一个改动最小的方案。

既然我们都是使用了Activity实现了很多业务接口,同时构造其它业务方对象的时候都是将Activity以Context的形式传入,那么如果我们能将Page变成Context,将Page实现所有业务的接口,Page其实就等价于一个Activity了,这样就不需要改其它业务方的代码即可完美的迁移了。 因此问题就简单了,只要我们把Page变成Context的子类即可。

直接继承Context是不明智的,需要实现的方法太多,系统已经帮我们做了一个ContextWrapper,Application,Service和Activity等都是继承自它。ContextWrapper 是 ContextImpl(也就是BaseContext) 的包装,所有对 ContextWrapper 的操作都会用代理到 BaseContext 上。因为我们只需将Page继承自ContextWrapper,然后将 Activity 作为 BaseContext 来构造 Page 即可。这样就能以尽量少的改动来解决Context的问题了。代码大致如下:

public abstract class Page extends ContextWrapper {

    public Page(Activity base) {
        super(base);
    }
}

Page在新详情页交互中的应用

解决了上面的问题,下面就是迁移过程了,大致思路如下:

  1. 将详情页分为两个区域:播放器容器View和详情页内容View。播放器容器为列表和详情页共享。内容区域为每次打开详情页重新创建;
  2. 在主界面首次启动的时候,创建一个只含有播放器容器View的Page;
  3. 列表播放视频的时候,将TextureView attach到Page的播放器容器中,来实现列表播放;
  4. 当打开详情页的时候,播放器容器View动画移动到顶部,创建并加载详情页内容view;
  5. 当关闭详情页的时候,将详情页内容View从Page中移除,播放器容器移动到列表中对应的位置。

演示如下:

性能

以Nexus5/Android6.0设备测试20次数据:

内存占用对比

打开详情页前占用内存 打开详情页占用内存
旧详情页 22.49m 28.42m
新详情页 22.49m 26.95m

内存占用明显减小。

CPU占用对比

旧详情页 新详情页
峰值 28.9% 27.1%

CPU占用上降幅不大。

使用Page后的内存泄露处理

解决了上述问题后,基本算是完美运行了,但是使用一段时候后,发现详情页内容View虽然在Page销毁的时候移除了,但是其实例还是没法回收。

Class Name                                                                                           | Shallow Heap | Retained Heap  
------------------------------------------------------------------------------------------------------------------------------------
com.xxx.DetailContentView @ 0x13014800                                                               |        1,056 |        14,912  
'- mParent com.xxx.MyListView @ 0x13015000                                                           |        1,104 |         3,688  
   '- this$0 android.widget.AbsListView$ListItemAccessibilityDelegate @ 0x12f67300                   |           16 |            16
      '- mAccessibilityDelegate android.widget.LinearLayout @ 0x13245000                             |          608 |           920
         '- mParent android.widget.RelativeLayout @ 0x132e2000                                       |          584 |         1,816
            '- mParent com.xxx.StickyGridView @ 0x13015800                                           |        1,056 |         2,440
               '- [3] java.lang.Object[12] @ 0x1387c9c0                                              |           64 |            64
                  '- array java.util.ArrayList @ 0x136c0de0                                          |           24 |            88
                     '- mScrollContainers android.view.View$AttachInfo @ 0x12c4fb50                  |          232 |         1,568
                        |- mAttachInfo com.android.internal.policy.PhoneWindow$DecorView @ 0x132e8000|          712 |         1,840
                        |  |- mCurRootView android.view.inputmethod.InputMethodManager @ 0x12d62470  |          136 |           792
                        |  |- mDecor com.xxx.MainActivity @ 0x12c776c0                               |          480 |         1,808
------------------------------------------------------------------------------------------------------------------------------------

泄露原因:

在某些带滚动条的控件中, 如ListView和GridView中(也就是ScrollContainer), 会在创建的时候将其添加到 android.view.View.AttachInfo#mScrollContainers 中。

当 android.view.View#dispatchDetachedFromWindow 或者 android.view.View#setScrollContainer(boolean) 方法才从 mScrollContainers 中移除。

而在ListView中对移出屏幕的view回收的时候只会调用 android.view.ViewGroup#detachViewsFromParent(int, int),而这个方法中是没有对View调用 android.view.View#dispatchDetachedFromWindow 的。

从而ListView中的 ScrollContainer 当不在屏幕中的时候, 即使ListView已经detached了, 但是ScrollContainer还是无法从mScrollContainers中移除。

解决方案:

当ListView不再使用的时候从ListView中取出所有的View, 手动触发其 dispatchDetachedFromWindow 方法。

后续规划

  1. 小窗播放:对详情页基于Page改造后,详情页变得更轻,同时因为实质上就是一个View,因此对动画的支持也更好,后续如果加小窗播放,也变得很方便。
  2. Page+Activity的嵌套使用:Page本身支持了完整的生命周期,因此Page可以和依附于一个单独的Activity来运行,后续可以实现Page+Activity的方式来实现一些新的页面,后续如果有类似于新详情页交互的改造都会很方便。

参考

  1. Android Transition动画介绍
  2. Fresco共享动画问题