Jetpack 系列(12)—— OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势

·  阅读 4948
Jetpack 系列(12)—— OnBackPressedDispatcher:Jetpack 处理回退事件的新姿势

你的支持对我意义重大!

🔥 Hi,我是小彭。本文已收录到 GitHub · Android-NoteBook 中。这里有 Android 进阶成长路线笔记 & 博客,有志同道合的朋友,关注公众号 [彭旭锐] 跟着我一起成长。

前言

  • androidx.activity 1.0.0 开始,Google 引入 OnBackPressedDispatcher API 来处理回退事件,旨在优化回退事件处理:你可以在任何位置定义回退逻辑,而不是依赖于 Activity#onBackPressed();
  • 在这篇文章里,我将介绍 OnBackPressedDispatcher 的使用方法 & 实现原理 & 应用场景。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。
  • 本文相关代码可以从 DemoHall·HelloAndroidX 下载查看。

这篇文章是 Jetpack 系列文章第 12 篇,专栏文章列表:

二、其他:

  • 1、AppStartup:轻量级初始化框架
  • 2、DataStore:新一代键值对存储方案
  • 3、Room:ORM 数据库访问框架
  • 4、WindowManager:加强对多窗口模式的支持
  • 5、WorkManager:加强对后台任务的支持
  • 6、Compose:新一代视图开发方案

目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~


1. 概述

  • OnBackPressedDispatcher 解决了什么问题: 在 Activity 里可以通过回调方法 onBackPressed() 处理,而 Fragment / View 却没有直接的回调方法。现在,我们可以使用 OnBackPressedDispatcher 替代 Activity#onBackPressed(),更优雅地实现回退逻辑。

  • OnBackPressedDispatcher 的整体处理流程: 分发器整体采用责任链设计模式,向分发器添加的回调对象都会成为责任链上的一个节点。当用户触发返回键时,将按顺序遍历责任链,如果回调对象是启用状态(Enabled),则会消费该回退事件,并且停止遍历。如果最后事件没有被消费,则交回到 Activity#onBackPressed() 处理。

  • OnBackPressedDispatcher 与其他方案对比: 在 OnBackPressedDispatcher 之前,我们只能通过 “取巧” 的方法处理回退事件:

    • 1、在 Fragment 中定义回调方法,从 Activity#onBackPressed() 中传递回调事件(缺点:增加了 Activity & Fragment 的耦合关系);
    • 2、在 Fragment 根布局中设置按键监听 setOnKeyListener(缺点:不灵活 & 多个 Fragment 监听冲突)。

2. OnBackPressedDispatcher 有哪些 API?

主要有以下几个,其他这几个 API 都比较好理解。其中 addCallback(LifecycleOwner, callback) 会在生命周期持有者 LifecycleOwner 进入 Lifecycle.State.STARTED 状态,才会加入分发责任链,而在 LifecycleOwner 进入 Lifecycle.State.STOP 状态时,会从分发责任链中移除。

1、添加回调对象
public void addCallback(OnBackPressedCallback onBackPressedCallback)

2、添加回调对象,关联到指定生命周期持有者
public void addCallback(LifecycleOwner owner, OnBackPressedCallback onBackPressedCallback)

3、判断是否有启用的回调
public boolean hasEnabledCallbacks()

4、回退事件分发入口
public void onBackPressed()

5、构造器(参数为最终回调)
public OnBackPressedDispatcher(@Nullable Runnable fallbackOnBackPressed) {
    mFallbackOnBackPressed = fallbackOnBackPressed;
}
复制代码

3. OnBackPressedDispatcher 源码分析

OnBackPressedDispatcher 源码不多,我直接带着问题入手,帮你梳理 OnBackPressedDispatcher 内部的实现原理:

3.1 Activity 如何将事件分发到 OnBackPressedDispatcher?

答:ComponentActivity 内部组合了分发器对象,返回键回调 onBackPressed() 会直接分发给 OnBackPressedDispatcher#onBackPressed()。另外,Activity 本身的回退逻辑则封装为 Runnable 交给分发器处理。

androidx.activity.ComponentActivity.java

private final OnBackPressedDispatcher mOnBackPressedDispatcher =
    new OnBackPressedDispatcher(new Runnable() {
        @Override
        public void run() {
            // Activity 本身的回退逻辑
            ComponentActivity.super.onBackPressed();
        }
});

@Override
@MainThread
public void onBackPressed() {
    mOnBackPressedDispatcher.onBackPressed();
}

@NonNull
@Override
public final OnBackPressedDispatcher getOnBackPressedDispatcher() {
    return mOnBackPressedDispatcher;
}
复制代码

3.2 说一下 OnBackPressedDispatcher 的处理流程?

答:分发器整体采用责任链设计模式,向分发器添加的回调对象都会成为责任链上的一个节点。当用户触发返回键时,将按顺序遍历责任链,如果回调对象是启用状态(Enabled),则会消费该回退事件,并且停止遍历。如果最后事件没有被消费,则交回到 Activity#onBackPressed() 处理。

OnBackPressedDispatcher.java

// final 回调:Activity#onBackPressed()
@Nullable
private final Runnable mFallbackOnBackPressed;

// 责任链
final ArrayDeque<OnBackPressedCallback> mOnBackPressedCallbacks = new ArrayDeque<>();

// 构造器
public OnBackPressedDispatcher() {
    this(null);
}

// 构造器
public OnBackPressedDispatcher(@Nullable Runnable fallbackOnBackPressed) {
    mFallbackOnBackPressed = fallbackOnBackPressed;
}

// 判断是否有启用的回调
@MainThread
public boolean hasEnabledCallbacks() {
    Iterator<OnBackPressedCallback> iterator = mOnBackPressedCallbacks.descendingIterator();
    while (iterator.hasNext()) {
        if (iterator.next().isEnabled()) {
            return true;
        }
    }
    return false;
}

入口方法:责任链上的每个回调方法仅在前面的回调处于未启用状态(unEnabled)才能调用。
如果如果都没有启用,最后会回调给 mFallbackOnBackPressed
@MainThread
public void onBackPressed() {
    Iterator<OnBackPressedCallback> iterator = mOnBackPressedCallbacks.descendingIterator();
    while (iterator.hasNext()) {
        OnBackPressedCallback callback = iterator.next();
        if (callback.isEnabled()) {
            callback.handleOnBackPressed();
            // 消费
            return;
        }
    }
    if (mFallbackOnBackPressed != null) {
        mFallbackOnBackPressed.run();
    }
}
复制代码

3.3 回调方法执行在主线程还是子线程?

答:主线程,分发器的入口方法 Activity#onBackPressed() 执行在主线程,因此回调方法也是执行在主线程。另外,添加回调的 addCallback() 方法也要求在主线程执行,分发器内部使用非并发安全容器 ArrayDeque 存储回调对象。

3.4 OnBackPressedCallback 可以同时添加到不同分发器吗?

答:可以。

3.5 加入返回栈的Fragment 事务,如何回退?

答:FragmentManager 也将事务回退交给 OnBackPressedDispatcher 处理。首先,在 Fragment attach 时,会创建一个回调对象加入分发器,回调处理时弹出返回栈栈顶事务。不过初始状态是未启用,只有当事务添加进返回栈后,才会修改回调对象为启用状态。源码体现如下:

FragmentManagerImpl.java

// 3.5.1 分发器与回调对象(初始状态是未启用)
private OnBackPressedDispatcher mOnBackPressedDispatcher;
private final OnBackPressedCallback mOnBackPressedCallback =
    new OnBackPressedCallback(false) {
        @Override
        public void handleOnBackPressed() {
            execPendingActions();
            if (mOnBackPressedCallback.isEnabled()) {
                popBackStackImmediate();
            } else {
                mOnBackPressedDispatcher.onBackPressed();
            }
        }
    };

// 3.5.2 添加回调对象 addCallback
public void attachController(@NonNull FragmentHostCallback host, @NonNull FragmentContainer container, @Nullable final Fragment parent) {
    if (mHost != null) throw new IllegalStateException("Already attached");
    ...
    // Set up the OnBackPressedCallback
    if (host instanceof OnBackPressedDispatcherOwner) {
        OnBackPressedDispatcherOwner dispatcherOwner = ((OnBackPressedDispatcherOwner) host);
        mOnBackPressedDispatcher = dispatcherOwner.getOnBackPressedDispatcher();
        LifecycleOwner owner = parent != null ? parent : dispatcherOwner;
        mOnBackPressedDispatcher.addCallback(owner, mOnBackPressedCallback);
    }
    ...
}

// 3.5.3 执行事务时,尝试修改回调对象状态
void scheduleCommit() {
     ...
    updateOnBackPressedCallbackEnabled();
}

private void updateOnBackPressedCallbackEnabled() {
    if (mPendingActions != null && !mPendingActions.isEmpty()) {
        mOnBackPressedCallback.setEnabled(true);
        return;
    }

    mOnBackPressedCallback.setEnabled(getBackStackEntryCount() > 0 && isPrimaryNavigation(mParent));
}

// 3.5.4 回收
public void dispatchDestroy() {
    mDestroyed = true;
    ...
    if (mOnBackPressedDispatcher != null) {
        // mOnBackPressedDispatcher can hold a reference to the host
        // so we need to null it out to prevent memory leaks
        mOnBackPressedCallback.remove();
        mOnBackPressedDispatcher = null;
    }
}
复制代码

如果你对 Fragment 事务缺乏清晰的概念,务必看下我之前写的一篇文章:你真的懂 Fragment 吗?AndroidX Fragment 核心原理分析

讨论完 OnBackPressedDispatcher 的使用方法 & 实现原理,下面我们直接通过一些应用场景来实践:


4. 再按一次返回键退出

再按一次返回键退出是一个很常见的功能,本质上是一种退出挽回。网上也流传着很多不全面的实现方式。其实,这个功能看似简单,却隐藏着一些优化细节,一起来看看~

4.1 需求分析

首先,我分析了几十款知名的 App,梳理总结出 4 类返回键交互:

分类描述举例
1、系统默认行为返回键事件交给系统处理,应用不做干预微信、支付宝等
2、再按一次退出是否两秒内再次点击返回键,是则退出爱奇艺、高德等
3、返回首页 Tab按一次先返回首页 Tab,再按一次退出Facebook、Instagram等
4、刷新信息流按一次先刷新信息流,再按一次退出小红书、今日头条等

4.2 如何退出 App?

交互逻辑主要依赖于产品形态和具体应用场景,对于我们技术同学还需要考虑不同的退出 App 的方式的区别。通过观测以上 App 的实际效果,我梳理出以下 4 种退出 App 的实现方式:

  • 1、系统默认行为: 将回退事件交给系统处理,而系统的默认行为是 finish() 当前 Activity,如果当前 Activity 位于栈底,则将 Activity 任务栈转入后台;

  • 2、调用 moveTaskToBack(): 手动将当前 Activity 所在任务栈转入后台,效果与系统的默认行为类似(该方法接收一个 nonRoot 参数:true:要求只有当前 Activity 处于栈底有效、false:不要求当前 Activity 处于栈底)。因为 Activity 实际上并没有销毁,所以用户下次返回应用时是热启动;

  • 3、调用 finish(): 结束当前 Activity,如果当前 Activity 处于栈底,则销毁 Activity 任务栈,如果当前 Activity 是进程最后一个组件,则进程也会结束。需要注意的时,进程结束后内存不会立即被回收,将来(一段时间内)用户重新启动应用为温启动,启动速度比冷启动更快;

  • 4、调用 System.exit(0) 杀死应用 杀死进程 JVM,将来用户重新启动为冷启动,需要花费更多时间。

那么,我们应该如何选择呢?一般情况下,“调用 moveTaskToBack()” 表现最佳,两个论点:

  • 1、两次点击返回键的目的是挽回用户,确认用户真的需要退出。那么,退出后的行为与无拦截的默认行为相同,这点 moveTaskToBack() 可以满足,而 finish() 和 System.exit(0) 的行为比默认行为更严重;

  • 2、moveTaskToBack() 退出应用并没有真正销毁应用,用户重新返回应用是热启动,恢复速度最快。

需要注意,一般不推荐使用 System.exit(0) 和 Process.killProcess(Process.myPid) 来退出应用。因为这些 API 的表现并不理想:

  • 1、当调用的 Activity 不位于栈顶时,杀死进程系统会立即重新启动 App(可能是系统认为 前台 App 是意外终止的,会自动重启);

  • 2、当 App 退出后,粘性服务会自动重启(Service#onStartCommand() 返回 START_STICKY 的 Service),粘性服务会一致运行除非手动停止。

分类应用返回效果举例
1、系统默认行为热启动微信、支付宝等
2、调用 moveTaskToBack()热启动QQ 音乐、小红书等
3、调用 finish()温启动待确认(备选爱奇艺、高德等)
4、调用 System.exit(0) 杀死应用冷启动待确认(备选爱奇艺、高德等)

Process.killProcess(Process.myPid) 和 System.exit(0) 的区别? todo

4.3 具体代码实现

BackPressActivity.kt

fun Context.startBackPressActivity() {
    startActivity(Intent(this, BackPressActivity::class.java))
}

class BackPressActivity : AppCompatActivity(R.layout.activity_backpress) {

    // ViewBinding + Kotlin 委托
    private val binding by viewBinding(ActivityBackpressBinding::bind)

    /**
     * 上次点击返回键的时间
     */
    private var lastBackPressTime = -1L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 添加回调对象
        onBackPressedDispatcher.addCallback(this, onBackPress)

        // 返回按钮
        binding.ivBack.setOnClickListener {
            onBackPressed()
        }
    }

    private val onBackPress = object : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            if (popBackStack()) {
                return
            }
            val currentTIme = System.currentTimeMillis()
            if (lastBackPressTime == -1L || currentTIme - lastBackPressTime >= 2000) {
                // 显示提示信息
                showBackPressTip()
                // 记录时间
                lastBackPressTime = currentTIme
            } else {
                //退出应用
                finish()
                // android.os.Process.killProcess(android.os.Process.myPid())
                // System.exit(0) // exitProcess(0)
                // moveTaskToBack(false)
            }
        }
    }

    private fun showBackPressTip() {
        Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
    }
}
复制代码

这段代码的逻辑并不复杂,我们主要通过 OnBackPressedDispatcher#addCallback() 添加了一个回调对象,从而干预了返回键事件的逻辑:“首次点击返回键弹出提示,两秒内再次点击返回键退出应用”。

另外,需要解释下这句代码: private val binding by viewBinding(ActivityBackpressBinding::bind)。这里其实是使用了 ViewBinding + Kotlin 委托属性的视图绑定方案,相对于传统的 findViewById、ButterKnife、Kotlin Synthetics 等方案,这个方案从多个角度上表现更好。具体分析你可以看我之前写过的一篇文章:Android | ViewBinding 与 Kotlin 委托双剑合璧

4.4 优化:兼容 Fragment 返回栈

上一节基本能满足需求,但考虑一种情况:页面内有多个 Fragment 事务加入了返回栈,点击返回键时需要先依次清空返回栈,最后再走 “再按一次返回键退出” 逻辑。

此时,你会发现上一节的方法不会等返回栈清空就直接走退出逻辑了。原因也很好理解,因为 Activity 的回退对象的加入时机比 FragmentManagerImpl 中的回退对象加入时机更早,所以 Activity 的回退逻辑优先处理。解决方法就是在 Activtiy 回退逻辑中手动弹出 Fragment 事务返回栈。完整演示代码如下:

BackPressActivity.kt

class BackPressActivity : AppCompatActivity(R.layout.activity_backpress) {

    private val binding by viewBinding(ActivityBackpressBinding::bind)

    /**
     * 上次点击返回键的时间
     */
    private var lastBackPressTime = -1L

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        addFragmentToStack()
        onBackPressedDispatcher.addCallback(this, onBackPress)

        binding.ivBack.setOnClickListener {
            onBackPressed()
        }
    }

    private fun addFragmentToStack() {
        // 提示:为了聚焦问题,这里不考虑 Activity 重建的场景
        for (index in 1..5) {
            supportFragmentManager.beginTransaction().let { it ->
                it.add(
                    R.id.container,
                    BackPressFragment().also { it.text = "fragment_$index" },
                    "fragment_$index"
                )
                it.addToBackStack(null)
                it.commit()
            }
        }
    }

    /**
     * @return true:没有Fragment弹出 false:有Fragment弹出
     */
    private fun popBackStack(): Boolean {
        // 当 Fragment 状态以保存,不弹出返回栈
        return supportFragmentManager.isStateSaved
                || supportFragmentManager.popBackStackImmediate()
    }

    private val onBackPress = object : OnBackPressedCallback(true) {
        override fun handleOnBackPressed() {
            if (popBackStack()) {
                return
            }
            val currentTIme = System.currentTimeMillis()
            if (lastBackPressTime == -1L || currentTIme - lastBackPressTime >= 2000) {
                // 显示提示信息
                showBackPressTip()
                // 记录时间
                lastBackPressTime = currentTIme
            } else {
                //退出应用
                finish()
                // android.os.Process.killProcess(android.os.Process.myPid())
                // System.exit(0) // exitProcess(0)
                // moveTaskToBack(false)
            }
        }
    }

    private fun showBackPressTip() {
        Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
    }
}
复制代码

4.5 在 Fragment 中使用

TestFragment.kt

class TestFragment : Fragment() {
    private val dispatcher by lazy {requireActivity().onBackPressedDispatcher}
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        dispatcher.addCallback(this, object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                Toast.makeText(context, "TestFragment - handleOnBackPressed", Toast.LENGTH_SHORT).show()
            }
        })
    }
}
复制代码

4.6 其他 finish() 方法

另外,finish() 还有一些类似的 API,可以补充了解下:

  • finishAffinity():关闭当前 Activity 任务栈中,位于当前 Activity 底下的所有 Activity(例如 A 启动 B,B 启动 C,如果 B 调用 finishAffinity(),则会关闭 A 和 B,而 C 保留)。该 API 在 API 16 后引入,最好通过 ActivityCompat.finishAffinity() 调用。
  • finishAfterTransition():执行转场动画后 finish Activity,需要通过 ActivityOptions 定义转场动画。该 API 在 API 21 后引入,最好通过 ActivityCompat.finishAfterTransition() 调用。

5. 总结

关于 OnBackPressedDispatcher 的讨论就先到这里,给你留两个思考题:

  • 1、如果 Activity 上弹出一个 Dialog,此时点返回键是先关闭 Dialog,还是会分发给 OnBackPressedDispatcher?如果弹出的是 PopupWindow 呢?
  • 2、Activity 的 WebView 中弹出了一个浮层,怎么实现点击返回键先关闭浮层,再次点击才回退页面?

参考资料


你的点赞对我意义重大!微信搜索公众号 [彭旭锐],希望大家可以一起讨论技术,找到志同道合的朋友,我们下次见!

收藏成功!
已添加到「」, 点击更改