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

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

Scene 最初用于解决西瓜视频的直播业务在演进过程当中遇到的问题,后来又在抖音的拍摄工具中落地,通过了实践与验证,因而团队以为将其开源到社区,但愿可以帮助你们在更多的场景解决问题。android

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

开发背景

西瓜视频面临的问题

西瓜视频在 1.0.8 版本有作过一次播放体验的优化,但愿首页正在播放的短视频跳转到详情页面时,可以有一个平滑的动画过渡。github

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

下面的视频是新版本的过分效果:框架

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

可是 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

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

相关文章
相关标签/搜索