开源 | Scene:Android 开源页面导航和组合框架

avatar
@字节跳动

Scene 是字节跳动技术团队开源的一款 Android 页面导航和组合框架,用于实现 Single Activity Applications,有着灵活的栈管理,页面拆分,以及完整的各种动画支持。

Scene 最初用于解决西瓜视频的直播业务在演进过程中遇到的问题,后来又在抖音的拍摄工具中落地,经过了实践与验证,于是团队觉得将其开源到社区,希望能够帮助大家在更多的场景解决问题。

Github 项目地址与使用文档:https://github.com/bytedance/scene

开发背景

西瓜视频面临的问题

西瓜视频在 1.0.8 版本有做过一次播放体验的优化,希望首页正在播放的短视频跳转到详情页面时,能够有一个平滑的动画过渡。

下面的视频是老版本的过度效果:

下面的视频是新版本的过度效果:

这种复杂的过渡动画,是不可能拿 Activity 实现的。然而 Fragment 在那个时候也会出现各种怪异的状态保存引发的崩溃(虽然知道崩溃的原理,但是不能接受这种设计),于是西瓜视频技术团队设计了名为 Page 的 UI 方案,来实现过渡动画这个需求。

但是 Page 本身跟业务耦合非常严重,没法单独抽出去给其他场景用。后来,随着西瓜直播业务的壮大,也有了需要类似框架的需求,为了解决 Activity 栈管理太弱、各种黑屏、动画能力太弱等问题,同时解决 Fragment 崩溃过多问题,我们开发了 Scene 这套通用的框架。

下面是西瓜长视频详情页和抖音拍摄页面使用Scene的场景截图:

西瓜的长视频页面和抖音的拍摄页面截图

Activity/Fragment 的不足

这里简单列下 Activity 和 Support 28 的 Fragment 的不足,部分问题已经在 Android X 的 Fragment 上修复了。

页面导航对比 Activity

  1. 栈管理弱,Intent+LaunchMode 的设计,使得开发者在使用的时候要么极容易出错,要么用 Hack 做对了但是动画过度黑屏;
  2. Activity 性能差,普通的空白页面切换也得 60、70ms 耗时(基于三星 S9 设备测试);
  3. 因为销毁恢复的强制要求:
    • 导致的 Activity 动画能力非常弱,无法直接拿到前后两个页面的 View 也就无法简单的实现复杂的交互动画;
    • SharedElement 动画能力弱,动画的瞬间不得不来回传递上下两个 Activity 各种控件的 Bitmap;
    • Android 9 之前 Activity 每次启动新的 Activity,都需要上个页面执行完 onSaveInstance,这一步影响了页面打开的速度;
  4. Activity 依赖 Manifest 给 Android 动态化增加了难度,需要对系统的 Instrumentation ActivityThread 进行各种 Hack ;
  5. 依赖注入很难,因为创建 Activity 对象的流程在 Android 8 之前是没有 API 暴露给外部处理的;
  6. 因为 Window 的机制导致做悬浮窗播放也是问题,导致实现窗口播放必须依赖了一个危险的悬浮窗权限;
  7. 共享元素动画在某些版本的 Framework 层有 NPE,无法解决。
java.lang.NullPointerException(android.app.EnterTransitionCoordinator);

页面组合对比 Fragment

  1. 各种奇怪的崩溃,就算不用 Fragment,但是用了 AppCompatActivity 还是会在 onBackPressed 里面触发崩溃;
java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState

对于这种情况,西瓜直接在自己的 Activity 基类对 super.onBackPressed() 进行了try catch。

  1. add/remove/hide/show 操作不是立刻执行,就算 commitNow 执行了 Fragment 的状态,也不能保证他的 Child Fragment 状态刷新到最新。在执行了 getChildFragmentManager().executePendingTransactions() 后,开发者会误以为 Child Fragment 都已经切到最新的 Parent Fragment 状态,其实并没有;
  2. Fragment 有两套 Lifecycle,View Lifecycle 和 Fragment 实例 Lifecycle;
  3. Fragment show/hide 方法不会触发生命周期回调,调用了 hide 不会触发 onPause/onStop,只是修改了 View 的可见性;
  4. Fragment 动画能力有限,只能使用资源文件,而且页面导航无法保证 Z 轴正确;
  5. 就算 Fragment 已经被销毁,但是 View.OnClickListener onClick 回调依然继续触发,导致回调内部不得不补大量的判空逻辑;
if (getActivity() == null) {
            return;
        }
  1. 导航功能非常弱,除了打开和关闭,没有更加高级的栈管理,导航的回调连顺序都保证不了,有可能一次导航触发多次回调;
  2. 原生 Fragment 和 Support Fragment 的生命周期并不完全相同;
  3. 同时支持 add/remove/hide/show+addToBackStack 使得 Fragment 的代码极度混乱。

Scene 框架

功能特点

Scene 提供页面导航页面组合两大功能,特点如下:

  1. 基于 View 实现,非常轻量;
  2. 只有一个 Lifecycle,View 销毁,那么 Scene 也会销毁,不会出现 Fragment 有两套 Lifecycle 的问题;
  3. 导航栈管理非常灵活,不会出现页面切换黑屏问题;
  4. 无论是导航操作还是组合操作,通常都是直接执行,不需要区分 commit 和 commitNow;
  5. 不强制要求状态保存,甚至可以把状态保存控制在页面级别,增强组件通讯的能力;
  6. 有完整的共享元素动画支持;
  7. 页面导航和页面组合功能可以独立使用。

基本概念

Scene 框架有3种基本组件:Scene、NavigationScene、GroupScene。

用处
Scene 所有 Scene 的基类,带生命周期和 View 支持的组件
NavigationScene 支持页面导航
GroupScene 支持将任何 Scene 组合

Scene

NavigationScene

GroupScene

Scene 使用

简单使用

这里介绍简单的上手,更多用法见 Github 仓库的示例。

接入

添加依赖:

dependencies {
  implementation 'com.bytedance.scene:scene:$latest_version'
  implementation 'com.bytedance.scene:scene-ui:$latest_version'
  implementation 'com.bytedance.scene:scene-shared-element-animation:$latest_version'

  // Kotlin
  implementation 'com.bytedance.scene:scene-ktx:$latest_version'
}

创建首页:

class MainScene : AppCompatScene() {
    override fun onCreateContentView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View? {
        return View(requireSceneContext())
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        setTitle("Main")
        toolbar?.navigationIcon = null
    }
}

创建 Activity:

class MainActivity : SceneActivity() {
    override fun getHomeSceneClass(): Class<out Scene> {
        return MainScene::class.java
    }

    override fun supportRestore(): Boolean {
        return false
    }
}

添加到 Manifest.xml,注意把输入法模式也改了:

<activity
    android:name=".MainActivity"
    android:windowSoftInputMode="adjustNothing">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

运行就可以了。

这是新应用想全部使用 Scene 写的方式。如果是老应用重构迁移,或者只想用页面组合替代 Fragment,导航依旧用 Activity 的做法,可以见 Github 的 Demo。

导航

打开新页面:

requireNavigationScene().push(TargetScene::class.java)

返回:

requireNavigationScene().pop()

打开页面拿结果:

requireNavigationScene().push(TargetScene::class.java, null,
        PushOptions.Builder().setPushResultCallback { result ->
            }
        }.build())

设置结果:

requireNavigationScene().setResult(this@TargetScene, YOUR_RESULT)

组合

组合的 API 类似 Fragment,继承 GroupScene,然后可以操作任意 Scene 添加到自己的 View 布局内:

void add(@IdRes int viewId, @NonNull Scene childScene, @NonNull String tag);
void remove(@NonNull Scene childScene);
void show(@NonNull Scene childScene);
void hide(@NonNull Scene childScene);
@Nullable
<T extends Scene> T findSceneByTag(@NonNull String tag);

示例:

class SecondScene : AppCompatScene() {
    private val mId: Int by lazy { View.generateViewId() }

    override fun onCreateContentView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View? {
        val frameLayout = FrameLayout(requireSceneContext())
        frameLayout.id = mId
        return frameLayout
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        setTitle("Second")
        add(mId, ChildScene(), "TAG")
    }
}
class ChildScene : Scene() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
        val view = View(requireSceneContext())
        view.setBackgroundColor(Color.GREEN)
        return view
    }
}

通讯

Scene 支持 ViewModel,可以通过 by activityViewModels,by viewModels 拿到托管到 Activity 或者自己的 ViewModel:

class ViewModelSceneSamples : GroupScene() {
    private val viewModel: SampleViewModel by activityViewModels()

示例:

class ViewModelSceneSamples : GroupScene() {
    private val viewModel: SampleViewModel by activityViewModels()
    private lateinit var textView: TextView

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel.counter.observe(this, Observer<Int> { t -> textView.text = "" + t })

        add(R.id.child, ViewModelSceneSamplesChild(), "Child")
    }
}

class ViewModelSceneSamplesChild : Scene() {
    private val viewModel: SampleViewModel by activityViewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
        return Button(requireSceneContext()).apply {
            text = "Click to +1"
        }
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        requireView().setOnClickListener {
            val countValue = viewModel.counter.value ?: 0
            viewModel.counter.value = countValue + 1
        }
    }
}

class SampleViewModel : ViewModel() {
    val counter: MutableLiveData<Int> = MutableLiveData()
}

动画

在 Push 的时候,通过 PushOptions 可以配置简单的过场动画:

val enter = R.anim.slide_in_from_right
val exit = R.anim.slide_out_to_left
requireNavigationScene().push(TargetScene::class.java, null,
        PushOptions.Builder().setAnimation(requireActivity(), enter, exit).build())

复杂的共享元素动画,手势动画,参考 Demo。

右划返回

Scene 内置右划返回手势,你直接继承 AppCompatScene,然后打开手势:

setSwipeEnabled(true)

核心设计思路

  1. Scene 本身是在 View 上面包一层生命周期,通过一个叫 LifeCycleFragment 的原生 Fragment 分发生命周期事件给框架内部,再由父组件同步给子组件。
  2. 父子组件同步生命周期,在原则上:
    • 进入的时候,先执行父组件的生命周期回调,再执行子组件的生命周期回调;
    • 退出的时候,先执行子组件的生命周期回调,再执行父组件的生命周期回调;
  3. NavigationScene 负责导航栈的处理,GroupScene 负责页面组合的处理,有点类似 iOS 的 UINavigationController/UIViewController,WinRT 的 Page。拆分的原因,是出于考虑性能,因为导航这个任务,由于动画的要求,本身的层级就会比普通的页面组合复杂,动画的 API 也更加强大。这两件事情,本身影响的生命周期也不一样,导航会影响之前的页面,而组合并不会。
  4. 生命周期和动画的处理原则是,先执行完生命周期,然后拿前后两个页面的 View 做动画,所以避免了Activity 动画需要在页面之间来回传递 Bitmap 来模拟控件这种繁琐的步骤,也避免了 Activity 动画黑屏的问题。
  5. 最后再由于 Transition 库过于无力,所以用系统核心的 GhostView,Scene 重头实现一遍共享元素动画。

未来与总结

Scene Router,开发中,以便可以支持流行的 Android 组件化开发。

Scene Dialog,开发中,用于解决 Android 框架的 Dialog 因为是基于 Window 会盖在普通的 View 之上的问题。

关于单 Activity 的想法,业界早在 Fragment 刚推出的时候就有探讨,社区诞生了 Conductor 之类的框架,甚至这2年,Google 官方也在做 Navigation Component,但是毕竟 Fragment 的坑太大,基于Fragment 做导航,总免不了受限于 Fragment 的兼容性,以至于后来,Google 为了解决这些兼容性问题,直接打算魔改 Fragment,废掉之前用了很多年的接口。

基于 View 重新实现的导航和组合方案,一方面是没有之前的技术债,一方面可以跳出 Google 的想法,比如说可以控制状态保存的范围,来实现更加强大的动画能力和组件通讯能力,这是官方的组件不会提供给开发者的。

仓库中的 Demo,已经把 Android 日常开发中大部分场景都补了示例,没有在本文中列出来的功能,可以参考 Demo 的写法。

参考资料

Single Activity: Why, When, and How (Android Dev Summit '18)

Fragments: Past, Present, and Future (Android Dev Summit '19)

Conductor

Uber RIBs

欢迎关注「字节跳动技术团队」