開源 | 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

歡迎關注「字節跳動技術團隊」

相關文章
相關標籤/搜索