Android 13 返回导航大变更:返回键彻底废弃 + 可预见型返回手势

本文详细介绍了Android13中引入的全局返回手势增强特性,包括可预见型返回手势、OnBackInvokedDispatcher API以及KEYCODE_BACK的废弃。文章分析了新特性对现有App的影响,提供了适配方案,包括使用AndroidX的OnBackPressedDispatcher和原生SDK API,并讨论了适配过程中的关键点和注意事项。适配新返回导航涉及修改回调逻辑,关闭新返回导航时的兼容性问题,以及注册和注销Callback的时机。文章还提醒开发者,随着Android14的推进,新返回导航可能成为强制要求,建议尽早适配。

Android 10 首次引入了全局返回手势,但直到返回触发才能看到目标上层画面。13 针对该特性进行了优化,即返回触发之前可以预览上层画面。同时彻底废弃了返回键相关的 API,这将对现有的 App 逻辑产生巨大的影响!

前言

Android 13 针对包括手机、大屏、折叠屏等 Android 设备推出了可预见型返回手势(Predictive Back Gesture)特性。该特性将便于用户在返回完成之前可以先预览到目标画面或结果,这样的话可以允许他们决定是否要继续返回或者放弃并停留在当前画面。

另外引入关于 KEYCODE_BACK KeyEvent 相关的一系列变更。

为节省篇幅和统一认识,后续的相关描述将按照如下规则简称:

  • 本次引入的可预见型返回手势 + KEYCODE_BACK 系列变更:统称为新返回导航
  • KEYCODE_BACK KeyEvent:简称为 KEYCODE_BACK
  • 传统导航模式和 Swipe-Up 导航模式下的返回按钮:简称为Back KeyButton
  • 全局返回手势:简称为Back Gesture
Back KeyButtonBack Gesture

后续将按照如下几个方面去阐述:

  1. 新返回导航的具体影响
  2. 如何确定是否受影响
  3. 适配方案的选择
  4. 适配方案的详述
  5. SDK API 适配方案的深入探讨
  6. 新返回导航支持与否的深入比较和原理分析
  7. 注意和残留事项

1. 新返回导航的具体影响

简单来说会产生如下影响:

  • 返回手势的可预见型 UI 的增强:展示返回触发前上层画面
  • 原有 API 废弃:
    • KEYCODE_BACK:详述见小章节
    • Activity/Dialog:onBackPressed()
  • 引入全新的 SDK 返回相关 API:
    • Manifest 中 enableOnBackInvokedCallback 属性
    • Activity/Dialog/Window:getOnBackInvokedDispatcher()
    • OnBackInvokedDispatcher
    • OnBackInvokedCallback

备注:无关TargetSDKVersion ,运行在 13 上只要支持新返回导航均会受收到如上的影响。

KEYCODE_BACK 非推荐

准确含义是 13 上一旦开启新返回导航支持,无论是 Back Gesture 的触发还是 Back KeyButton 的点击,App 均无法监听到 KEYCODE_BACK 事件。即相关的如下 API 将无法被回调:

  • Activity
    • dispatchKeyEvent()
    • onKeyDown()
    • onKeyUp()
    • onBackPressed()
  • Dialog:API 同上

2. 如何确定是否受影响

除了上述提到的具体变更以外,所有 KEYCODE_BACK 的相关逻辑都得测试一下是否存在问题,比如容易忽略的 View、Dialog$Builder。

简单来说,检查下现有代码是否用到了如下 API:

  1. Activity/Dialog#onBackPressed()
  2. Activity:dispatchKeyEvent()、onKeyDown()、onKeyUp(),监听 KEYCODE_BACK
  3. Activity:使用 AndroidX 的 OnBackPressedDispatcher、OnBackPressedCallback API
  4. Dialog:dispatchKeyEvent()、onKeyDown()、onKeyUp()、setOnKeyListener(),监听 KEYCODE_BACK
  5. AlertDialog$Builder:setOnKeyListener(),监听 KEYCODE_BACK
  6. View:dispatchKeyEvent()、onKeyDown()、onKeyUp()、setOnKeyListener(),监听 KEYCODE_BACK

3. 适配方案的选择

大多数 App 都会选择自定义返回导航,可选的方式包括 SDK 的原生 API 和 AndroidX 的 Callback API。依据这些情况的不同、App 适配的意愿不同,适配的方案也不一样。

没有自定义返回导航的场景

加入新返回导航的支持即可,具体见《4.1 加入新返回导航的支持》章节。

自定义返回导航的场景

需要按照现有 API 是否接入了 AndroidX 的 OnBackPressedDispatcher 进行分情况适配。

是否使用了AndroidX如何处理返回导航推荐的适配策略备注
YesAndroidX APIs升级已有的 AndroidX 返回 API3rd Party App 的大多数 case
Unsupported SDK APIs迁移非推荐 SDK 返回 API 到 AndroidX API少部分 App
NoUnsupported SDK APIs,但我愿意迁移迁移非推荐 SDK 返回 API 到新 SDK 返回 API系统 App 的大多数 case
Unsupported SDK APIs,但我不愿意迁移延迟加入新返回导航的支持,直到它成为必须特性少部分 App

4. 适配方案的详述

4.1 加入新返回导航的支持

Manifest 中针对新返回导航特性引入的属性 enableOnBackInvokedCallback 默认是 false,即默认不支持该特性,支持的话需要声明为 true。

<application
    ...
    android:enableOnBackInvokedCallback="true"
    ... >
...
</application>

实测发现:即便声明成了 false,但如果代码中残存了 13 的新 API(比如 OnBackInvokedCallback)的使用,仍会导致新返回导航发生作用。

也就是说,不支持的话,就不要使用任何新的返回相关 API。

4.2 关闭新返回导航的支持

正如上面所述,按照如下即可关闭对新返回导航的支持:

  1. enableOnBackInvokedCallback 声明为 false(不声明亦可)
  2. 不要使用 OnBackInvokedCallback 等返回相关 API

4.3 升级已有的 AndroidX 返回 API

对于已使用 AndroidX 返回 API 的 App 只需开启新返回导航的支持,其他的适配工作交由 AndroidX 框架来完成。

Supporting the predictive back gesture requires updating your app, using the OnBackPressedCallback AppCompat 1.6.0-alpha03 (AndroidX) or higher API.

笔者按照官方说明将 AppCompat 包升级到了 1.6.0-alpha03

dependencies {
    implementation 'androidx.appcompat:appcompat:1.6.0-alpha03'
}

使用其提供的 OnBackPressedCallback API 监听 Activity 的 Back 操作如下:

class BackKeyTestActivityAppCompat : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                Log.d("BackGesture", "Activity#handleOnBackPressed()")
            }
        })
    }

    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
        Log.d("BackGesture", "Activity#dispatchKeyEvent() event:$event")
        return super.dispatchKeyEvent(event)
    }

    override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
        Log.d("BackGesture", "Activity#onKeyDown() event:$event")
        return super.onKeyDown(keyCode, event)
    }

    override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
        Log.d("BackGesture", "Activity#onKeyUp() event:$event")
        return super.onKeyUp(keyCode, event)
    }

    override fun onBackPressed() {
        Log.d("BackGesture", "onBackPressed()")
        super.onBackPressed()
    }
}

可是实测发现:

  • 即便在 13 上开启了新返回导航,无论是 Back Gesture 还是 Back KeyButton,Callback 和 KeyEvent 回调均未执行,Activity 将直接结束
  • 但同样的代码运行在 12 上的话,Back Gesture 和 Back KeyButton 下 Callback 和 KeyEvent 均能被回调

12-Back Gesture 的执行日志:

05-31 10:35:28.732 11267 11267 D BackGesture: Activity#dispatchKeyEvent() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK, ... }
05-31 10:35:28.733 11267 11267 D BackGesture: Activity#onKeyDown() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK, ... }
05-31 10:35:28.733 11267 11267 D BackGesture: Activity#dispatchKeyEvent() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK, ... }
05-31 10:35:28.733 11267 11267 D BackGesture: Activity#onKeyUp() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK, ... }
05-31 10:35:28.733 11267 11267 D BackGesture: onBackPressed()
05-31 10:35:28.734 11267 11267 D BackGesture: Activity#handleOnBackPressed()

12-Back KeyButton 的执行日志:

05-31 10:37:21.724 11267 11267 D BackGesture: Activity#dispatchKeyEvent() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK... }
05-31 10:37:21.724 11267 11267 D BackGesture: Activity#onKeyDown() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK... }
05-31 10:37:21.846 11267 11267 D BackGesture: Activity#dispatchKeyEvent() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK... }
05-31 10:37:21.846 11267 11267 D BackGesture: Activity#onKeyUp() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK... }
05-31 10:37:21.846 11267 11267 D BackGesture: onBackPressed()
05-31 10:37:21.846 11267 11267 D BackGesture: Activity#handleOnBackPressed()

调试了一下,发现 AppCompat 框架里使用 13 的新 SDK API 前的版本判断有问题:

public class ComponentActivity {
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        if (Build.VERSION.SDK_INT >= 33) {
            mOnBackPressedDispatcher.setOnBackInvokedDispatcher(getOnBackInvokedDispatcher());
        }
        ...
    }
}

public final class OnBackPressedDispatcher {
    Cancellable addCancellableCallback(@NonNull OnBackPressedCallback onBackPressedCallback) {
        ...
        if (Build.VERSION.SDK_INT >= 33) {
            updateBackInvokedCallbackState();
            onBackPressedCallback.setIsEnabledConsumer(mEnabledConsumer);
        }
        return cancellable;
    }
}

Beta 版的 SDK_INT 常量仍然是 12L 的 32,到正式发布才会改为 33,所以版本判断应当使用 BuildCompat 的如下 API:

// BuildCompat.java
    public static boolean isAtLeastT() {
        return VERSION.SDK_INT >= 33
                || (VERSION.SDK_INT >= 32
                && isAtLeastPreReleaseCodename("Tiramisu", VERSION.CODENAME));
    }

官方文档提示说的是使用 1.6.0-alpha03 及以上,那么 03 应该是首次引入上述适配的版本,可能还没做好。查了下 AppCompat 包是否出现最新版本,果然有个 1.6.0-alpha04

Version 1.6.0-alpha04

​ May 18, 2022

更新了后确实好了,即 13 上开启支持的话,无论是 Back Gesture 还是 Back KeyButton,能像预期的那样都只会输出 androidX 版本的 Callback,Back 相关 KeyEvent 回调将不再执行

05-31 10:55:10.773  5041  5041 D BackGesture: Activity#handleOnBackPressed()

但仍有一点未达预期:

  • 按理说 13 上关闭支持的话,无论是 Back Gesture 还是 Back KeyButton,运行结果应该和 12 保持一致,即收到 Back 相关 KeyEvent 回调以及 OnBackPressedCallback
  • 可实测发现:只有 Back KeyButton 点击是上述结果,Back Gesture 的话只收到了 Callback、没有 KeyEvent 回调,这里有点奇怪

4.4 迁移非推荐 SDK 返回 API 到 AndroidX API

适配步骤:

  1. 迁移已有的系统返回处理逻辑到 AndroidX 的 OnBackPressedDispatcher API,他需要指定 OnBackPressedCallback 实现,详细的可参考如何提供自定义返回导航

    对于 Activity:

    class MyActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            val callback = onBackPressedDispatcher.addCallback(this) {
                // Handle the back button event
            }
        }
        ...
    }
    

    对于 Fragment:

    public class FormEntryFragment extends Fragment {
        @Override
        public void onAttach(@NonNull Context context) {
            super.onAttach(context);
            OnBackPressedCallback callback = new OnBackPressedCallback(
                true // default to enabled
            ) {
                @Override
                public void handleOnBackPressed() {
                    showAreYouSureDialog();
                }
            };
            requireActivity().getOnBackPressedDispatcher().addCallback(
                this, // LifecycleOwner
                callback);
        }
    }
    
  2. 禁用原有的系统返回手势回调,比如 onBackPressed()、KEYCODE_BACK

    解释:getOnBackPressedDispatcher 早在 13 之前就已经支持,既然换了就没必要保留 SDK API 逻辑。

  3. 最后记得加入新返回导航的支持。

4.5 迁移非推荐 SDK 返回 API 到新 SDK 返回 API

适配步骤:

  1. 运行在 13 及之后的版本上使用全新的 SDK API 即 OnBackInvokedCallback,12及之前的版本仍可使用旧的返回 API

  2. 在 Activity、Dialog、Window 等 Window 级别的组件里需要监听返回手势的逻辑处注册实现了 onBackInvoked 方法的 OnBackInvokedCallback。这将阻止当前的 Activity 被结束,这样的话当用户触发了系统返回操作的话你的 Callback 将有机会执行你预期的返回动作

  3. 为了确保正确支持系统“后退导航”的未来增强功能,你的 App 必须注销 OnBackInvokedCallback。否则,用户在使用系统后退导航时可能会看到不良行为,例如,在视图之间“卡住”并强制他们退出应用。

    To ensure that future enhancements to the system Back navigation are properly supported, your app MUST unregister the OnBackInvokedCallback. Otherwise, users may see undesirable behavior when using a system Back navigation—for example, “getting stuck” between views and forcing them to force quit your app.

    @Override
    void onCreate() {
      if (BuildCompat.isAtLeastT()) {
        getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
            OnBackInvokedDispatcher.PRIORITY_DEFAULT,
            () -> {
                // ...
            }
        );
      }
    }
    

    比如 WebView 需要拦截返回手势以回退网页,当已经返回到主画面的时候应当注销该 Callback 让系统来处理 finish。

  4. 同样的,加入新返回导航的支持。

备注:onBackPressed() 逻辑保留也没有关系,并不会发生冲突,而且为了兼容 13 之前的系统功能本就应该保留。

registerOnBackInvokedCallback() 说明

registerOnBackInvokedCallback() 调用的时候需要提供如下两个参数:

  • priority按照注册的逆序进行,但如果是高优先级的先回调。可选范围:int 型,亦可选如下预设常量:

    • PRIORITY_DEFAULT:值为 0,普通回调
    • PRIORITY_OVERLAY:值为 1000000,优先回调

    但不可以是负值、否则会发生 IllegalArgumentException 异常

    java.lang.IllegalArgumentException: Application registered OnBackInvokedCallback cannot have negative priority. Priority: -1

  • callback:OnBackInvokedCallback 实例,会在 Back Gesture 触发、Back KeyButton 按压的时候被回调

    实际结果:只有最后一个 register 的 Callback 得到调用,但如果列表里存在 PRIORITY_OVERLAY 等更高优先级的 Callback 的话则优先。与如下描述不符:

    When back is triggered, callbacks on the in-focus window are invoked in reverse order in which they are added within the same priority. Between different priorities, callbacks with higher priority are invoked first.

5. SDK API 适配方案的深入探讨

5.1 案例

和 KEYCODE_BACK 相关的有很多 API 可以处理、场景也很繁杂,简单举例如下:

  1. 覆写 Activity#onKeyDown() 处理 KEYCODE_BACK 的 DOWN:
class Activity {
    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        if ( ... ) return false

        when (keyCode) {
            KeyEvent.KEYCODE_BACK -> { methodA() }
            KeyEvent.KEYCODE_MENU -> { ... }
            else -> {}
        }

        return if ( ... ) {
            true
        } else super.onKeyDown(keyCode, event)
    }
}
  1. 覆写 Activity#onKeyUp() 处理 KEYCODE_BACK 的 UP

  2. 覆写 Activity#dispatchKeyEvent() 将 KeyEvent 传递到 Fragment 处理

  3. 覆写 Activity#onBackPressed() 处理返回回调

  4. 调用 Dialog#setOnKeyListener() 处理 KEYCODE_BACK

  5. 调用 AlertDialog.Builder#setOnKeyListener() 处理 KEYCODE_BACK

  6. 覆写 Dialog#dispatchKeyEvent() 处理 KEYCODE_BACK

  7. 覆写 EditText#onKeyPreIme() 处理 KEYCODE_BACK

  8. 甚至还有覆写 View 的 dispatchKeyEvent() 等函数处理 KEYCODE_BACK

5.2 适配

适配的目的在于确保如下:

  • 12 及以前的设备上 Back Gesture、Back KeyButton 以及其他 Key 抵达的时候,onKeyUp() 等回调能正常收到
  • 13 上开启新返回导航支持的话:Back Gesture 和 Back KeyButton 能在对应的 Callback 里回调,并和之前的 Back 动作保持一致。同时,其他 Key 仍能在 onKeyUp() 等原有函数里监听到

以上述的案例 1 的代码为例,如下是如何改造以保证能在 12 和 13 上运行一样的 Key 相关动作:

class Activity {
    private var onBackInvokedCallback: OnBackInvokedCallback? = null

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

        if (BuildCompat.isAtLeastT()) {
            onBackInvokedCallback = OnBackInvokedCallback {
                onBackEvent()
            }.also {
                onBackInvokedDispatcher.registerOnBackInvokedCallback(
                    OnBackInvokedDispatcher.PRIORITY_DEFAULT,
                    it
                )
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        if (BuildCompat.isAtLeastT()) {
            onBackInvokedCallback?.let {
                onBackInvokedDispatcher.unregisterOnBackInvokedCallback(it)
            }
        }
    }

    private fun onBackEvent() {
        // if ( ... ) return false
        if ( ... ) return

        // when (keyCode) {
        //     KeyEvent.KEYCODE_BACK -> { methodA() }
        //     KeyEvent.KEYCODE_MENU -> { ... }
        //     else -> {}
        // }
        methodA()

        // return if ( ... ) {
        //     true
        // } else super.onKeyDown(keyCode, event)
    }

    // 为兼容旧版仍需完全保留
    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        ...
    }
}

如上适配的关键点在于:除了在 Manifest 中将 enableOnBackInvokedCallback 属性打开和注册 OnBackInvokedCallback() 以外,重点在于如何实现 onBackInvoked() 来达到旧版的同等返回逻辑:

  • 删除掉 Back KeyButton 以外的逻辑,因为 Callback 只针对 Back 事件,没有可能收到其他 KEY 事件
  • 删除掉 KEYCODE_BACK 的检查,因为 Callback 只针对 Back 事件、没有必要检查
  • 按照原有的 dispatchtKeyEvent()、onKeyDown()、onKeyUp() 的逻辑决定 return true、false 以及 super 的改写办法
  • 兼容旧版本保留所有的 KeyEvent 的处理逻辑

此外,需要留意如下一些细节:

  • 新的 Callback 如何区分 dispatchKeyEvent()、onKeyDown()、onKeyUp() 的时机?

    无法区分,开启新返回导航之后只有一个 OnBackInvokedCallback 回调时机,其在 Back Gesture TriggerBack KeyButton Up 时触发。

    原本时序:dispatchKeyEvent(DOWN) -> onKeyDown() -> dispatchKeyEvent(UP) -> onKeyUp()

  • 新的 Callback 如何针对 KEYCODE_BACK 的 DOWN 和 UP 作区分?

    无法区分,开启新返回导航之后只有最终的 Callback,没有 DOWN 和 UP 之分。

  • 新的 Callback 针对 dispatchKeyEvent() 等处理的 return true、false、super 如何区分?

    • false:本意是不处理,对应于现在的 Callback 可以是什么也不做或直接 return
    • true:本意是处理,对应于现在的 Callback 可以是处理外加 return
    • super:本意是交由父类处理,对应于现在的 Callback 可以是 return 或者直接删除,这取决于原来的 super 调用位置,也可以考虑在某条件满足的时候提前注销 Callback 这种思路
  • Back 以外,比如 Menu KeyEvent 的监听是否受影响?

    不受影响。之前的 Menu Key 等监听在 13 上仍可以监听到、正常运行,可以保留。

  • 如何兼容 13 以前的版本呢?

    新老处理共存,判断运行版本:13 上开启的话执行新逻辑,13 以前继续沿用旧逻辑。

5.3 集成到 Base 中统一处理

Activity、Fragment 以及 Dialog 众多的情况下,可在 Base 类里加入统一的注册和销毁 Callback 的复用代码。

为了不干预不需要处理的子类,默认不进行注册。需要的子类覆写 isNeedInterceptBackEvent() 返回 true 并实现自己的 Callback 逻辑即可。

如下的 BaseActivity 事例代码:

open class BaseActivity: AppCompatActivity() {
    private var onBackInvokedCallback: OnBackInvokedCallback? = null

    /**
     * Inner class for handle back callback totally.
     */
    internal class OnBackInvokedCallbackInner constructor(baseActivity: BaseActivity) :
        OnBackInvokedCallback {
        private val activity: WeakReference<BaseActivity>

        override fun onBackInvoked() {
            activity.get()?.apply {
                onBackEvent()
            }
        }

        init {
            activity = WeakReference(baseActivity)
        }
    }

    /**
     * Override this method and return true if child wanna handle back event.
     */
    open fun isNeedInterceptBackEvent(): Boolean = false

    /**
     * Default back operation is invoking onBackPressed().
     * Child activity could override and implement its own operation.
     */
    open fun onBackEvent() {
        onBackPressed()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        if (isNeedInterceptBackEvent() && BuildCompat.isAtLeastT()) {
            onBackInvokedCallback = OnBackInvokedCallbackInner(this).also {
                onBackInvokedDispatcher.registerOnBackInvokedCallback(
                    OnBackInvokedDispatcher.PRIORITY_DEFAULT,
                    it
                )
            }
        }
    }

    override fun onDestroy() {
        ...
        if (BuildCompat.isAtLeastT()) {
            onBackInvokedCallback?.let {
                onBackInvokedDispatcher.unregisterOnBackInvokedCallback(it)
            }
        }
    }
}

需要的子类进行覆写。

class BackKeyHandleActivity : BaseActivity() {
    ...
    override fun isNeedInterceptBackEvent(): Boolean = true

    override fun onBackEvent() { ... }

    // 兼容 13 之前的逻辑
    override fun onBackPressed() { ... }
}

6. 新返回导航支持与否的深入比较和原理分析

针对采用新 SDK 返回 API 方案分别在 13 上开启和关闭新返回导航的支持,观察 KeyEvent 相关的 Log 输出,并尝试分析一些原理方面的差异。

6.1 开启支持

Back Gesture

开启新的返回手势支持的话,只能收到 OnBackInvokedCallback 回调,确实无法像以前一样灵活、精细地处理 KEYCODE_BACK 了。

如下的系统日志可以瞥见 Callback 处理的一些细节。

05-26 10:26:27.929   787   787 D NoBackGesture: Start gesture: MotionEvent { action=ACTION_DOWN ... }
05-26 10:26:27.929   787   787 D NoBackGesture: Prediction [1653531987929,47,633,-1,0.000000,1]
05-26 10:26:27.930   787   787 D NoBackGesture: reset mTriggerBack=false
05-26 10:26:27.931   787   852 D ShellBackPreview: initAnimation mMotionStarted=false
05-26 10:26:27.932   787   787 D NoBackGesture: Gesture [1653531987932,alw=TRUE,TRUE,TRUE,FALSE,disp=Point(1080, 2340),wl=82,il=0,wr=82,ir=0,excl=SkRegion()]
05-26 10:26:27.933   599  2725 D CoreBackPreview: Focused window found using getFocusedWindowToken
05-26 10:26:27.933   599  2725 D CoreBackPreview: startBackNavigation currentTask=Task{1d3c440 #502 type= ...}, callbackInfo=OnBackInvokedCallbackInfo{ ... }
05-26 10:26:27.934   787   852 D ShellBackPreview: Received backNavigationInfo:BackNavigationInfo{...}
05-26 10:26:27.963   787   787 D OnBackInvokedDispatcher: ViewRootImpl.registerBackCallbackOnWindow. Dispatcher:android.window.WindowOnBackInvokedDispatcher@be64a11 Package:com.android.systemui IWindow:android.view.ViewRootImpl$W@5c4e776 Session:android.view.IWindowSession$Stub$Proxy@3998bd7
05-26 10:26:27.968   787   787 V OnBackInvokedDispatcher: Proxy setActual android.window.WindowOnBackInvokedDispatcher@be64a11. Current null
05-26 10:26:27.968   787   787 V OnBackInvokedDispatcher: Proxy transferring 0 callbacks to android.window.WindowOnBackInvokedDispatcher@be64a11
05-26 10:26:28.271  3978  3978 D BackGesture: onBackInvoked()

通过 adb shell dumpsys input 命令确实也没有看到 InputFlinger 发送 KEYCODE_BACK 的记录。

    MotionEvent(deviceId=8, eventTime=2965229468000, source=TOUCHSCREEN | STYLUS, displayId=0, action=DOWN ...)
    MotionEvent(deviceId=8, eventTime=2965457324000, source=TOUCHSCREEN | STYLUS, displayId=0, action=MOVE ...)
    ...
    MotionEvent(deviceId=8, eventTime=2965524225000, source=TOUCHSCREEN | STYLUS, displayId=0, action=UP ...)
Back KeyButton

Back KeyButton 场景也是一样,开启新返回导航支持的话,只能收到 OnBackInvokedCallback 回调。

05-26 10:59:05.854  4497  4497 D OnBackInvokedDispatcher: ViewRootImpl.registerBackCallbackOnWindow. Dispatcher:android.window.WindowOnBackInvokedDispatcher@ad0c7c7 Package:com.android.systemui IWindow:android.view.ViewRootImpl$W@2ade1f4 Session:android.view.IWindowSession$Stub$Proxy@f606e17
05-26 10:59:05.904  4497  4497 V OnBackInvokedDispatcher: Proxy setActual android.window.WindowOnBackInvokedDispatcher@ad0c7c7. Current null
05-26 10:59:05.904  4497  4497 V OnBackInvokedDispatcher: Proxy transferring 0 callbacks to android.window.WindowOnBackInvokedDispatcher@ad0c7c7
05-26 10:59:05.977  7700  7700 D BackGesture: onBackInvoked()
05-26 10:59:06.495  7700  7700 V OnBackInvokedDispatcher: Proxy unregister android.app.Activity$$ExternalSyntheticLambda0@a72f76. Actual=android.window.WindowOnBackInvokedDispatcher@1da92aa
05-26 10:59:06.495  7700  7700 V OnBackInvokedDispatcher: Proxy unregister com.example.tiramisu_demo.MainActivity$$ExternalSyntheticLambda1@d37f96a. Actual=android.window.WindowOnBackInvokedDispatcher@1da92aa
05-26 10:59:27.696  4497  4497 D OnBackInvokedDispatcher: ViewRootImpl.registerBackCallbackOnWindow. Dispatcher:android.window.WindowOnBackInvokedDispatcher@cdfd9c Package:com.android.systemui IWindow:android.view.ViewRootImpl$W@da2b3a5 Session:android.view.IWindowSession$Stub$Proxy@f606e17
05-26 10:59:27.707  4497  4497 V OnBackInvokedDispatcher: Proxy setActual android.window.WindowOnBackInvokedDispatcher@cdfd9c. Current null
05-26 10:59:27.707  4497  4497 V OnBackInvokedDispatcher: Proxy transferring 0 callbacks to android.window.WindowOnBackInvokedDispatcher@cdfd9c

但 dump input 却出现了 Back 的 KeyEvent 记录,这是为什么呢?

此处留个悬念,后面会揭开谜底。

    MotionEvent(deviceId=8, eventTime=2276120343000, source=TOUCHSCREEN | STYLUS, displayId=0, action=DOWN ...)
    KeyEvent(deviceId=-1, eventTime=2276124000000, source=KEYBOARD, displayId=0, action=DOWN, flags=0x00000048, keyCode=BACK(4) ...)
    MotionEvent(deviceId=8, eventTime=2276205324000, source=TOUCHSCREEN | STYLUS, displayId=0, action=UP ...)
    KeyEvent(deviceId=-1, eventTime=2276266000000, source=KEYBOARD, displayId=0, action=UP, flags=0x00000048, keyCode=BACK(4) ...)

6.2 关闭支持

Back Gesture

当关闭支持后 Back Gesture 场景下能和旧版本一样收到 KEYCODE_BACK 了。

05-26 11:09:28.235  6784  6784 D BackGesture: dispatchKeyEvent() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK ... }
05-26 11:09:28.236  6784  6784 D BackGesture: dispatchKeyEvent() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK ... }
05-26 11:09:28.240  6784  6784 D BackGesture: onBackPressed()

dump input 也可以证实该 KeyEvent 的真实存在,而且可以看到 Back Gesture 的 UP 之后连续注入了 KEYCODE_BACK 的 DOWN 和 UP 的细节

    MotionEvent(deviceId=8, eventTime=585598303000, source=0x00005002, displayId=0, action=DOWN ...)
    MotionEvent(deviceId=8, eventTime=585812734000, source=0x00005002, displayId=0, action=MOVE ...)
    ...
    MotionEvent(deviceId=8, eventTime=585858936000, source=0x00005002, displayId=0, action=UP ...)
    KeyEvent(deviceId=-1, eventTime=585859000000, source=0x00000101, displayId=0, action=DOWN, flags=0x00000048, keyCode=4 ...)
    KeyEvent(deviceId=-1, eventTime=585860000000, source=0x00000101, displayId=0, action=UP, flags=0x00000048, keyCode=4 ...)
Back KeyButton

自不必说,Back KeyButton 的按下当然也可以收到 KEYCODE_BACK。

05-26 10:48:21.580  5817  5817 D BackGesture: dispatchKeyEvent() event:KeyEvent { action=ACTION_DOWN, keyCode=KEYCODE_BACK...}
05-26 10:48:21.635  5817  5817 D BackGesture: dispatchKeyEvent() event:KeyEvent { action=ACTION_UP, keyCode=KEYCODE_BACK ...}
05-26 10:48:21.635  5817  5817 D BackGesture: onBackPressed()

但与 Gesture 不同,dump input 的结果可以看到:在 Back KeyButton 上按下时注入了 KEYCODE_BACK 的 DOWN,抬起注入了 UP

    MotionEvent(deviceId=8, eventTime=352268883000, source=0x00005002, displayId=0, action=DOWN ...)
    KeyEvent(deviceId=-1, eventTime=352289000000, source=0x00000101, displayId=0, action=DOWN ...)
    MotionEvent(deviceId=8, eventTime=352378721000, source=0x00005002, displayId=0, action=UP ...)
    KeyEvent(deviceId=-1, eventTime=352386000000, source=0x00000101, displayId=0, action=UP...)

6.3 Back 相关时序的变化总结

对比点关闭支持-Back Gesture关闭支持-Back KeyButton开启支持-Back Gesture开启支持-Back KeyButton
ViewRootImpl#processKeyEvent()YESYESNOYES
dispatchKeyEvent(DOWN)YES
trigger 时连续发送 DOWN 和 UP
YES
按下时发送 DOWN
NONO
onKeyDown()YESYESNONO
dispatchKeyEvent(UP)YESYES
抬起时发送 UP
NONO
onKeyUp()YESYESNONO
onBackPressed()YESYESNONO
OnBackInvokedCallbackNONOYESYES

6.4 开启支持的原理分析

13 开启支持12
Back GestureCallback + KEYCODE_BACK 无法监听KEYCODE_BACK 可以监听
Back KeyButtonCallback + KEYCODE_BACK 无法监听
但 KEYCODE_BACK 实际存在
KEYCODE_BACK 可以监听

13 上开启支持之后,如果是点击 Back KeyButton,从 dump 来看仍然发出了 KEYCODE_BACK,猜测与你大体是这样:

  • Back Gesture 触发的时候,如果发现支持了 新返回导航,那就不再注入 KEYCODE_BACK,而是通过 Binder 告知 App 进程直接处理 Callback 的回调
  • Back KeyButton 仍然像以前一样注入 KEYCODE_BACK,但 ViewRootImpl 接收到该事件的时候,发现支持了 新返回导航,则没有向 View 树分发,而是取出 Callback 直接回调

这里不禁产生一个疑问:

  • Back Gesture 和 Back KeyButton 缘何没有采用同一个处理方式?

经过思考,觉得不免又如下几点可能:

  1. Back Gesture 和 Back KeyButton 功能定位有区别:前者是返回手势,需要展示返回图标和背面视图的动画,它的处理在 EdgeBackGesture 里;后者是虚拟按键,在 NavigationBar 的 KeyButtonView 中处理
    • 13 之前没有引入可预测型动画的时候两者功能雷同,所以 Back Gesture 采用了和 Back KeyButton 一样的逻辑
    • 13 引入了和 Back KeyButton 完全不同的返回预测动画,需要实现一套自己的回调路径,不需要再依赖原来的 KeyEvent 路径
  2. 另外从是否属于按键的角度上来讲
    • Back Gesture 不是虚拟按键、也不是实体按键,没有必要发送 KeyEvent
    • Back KeyButton 是虚拟按键,需要遵从 Key 的 Map 规范,是需要发送对应 KeyEvent 出来的。而且即便后面会被 App 拦截,但对于前期的系统 PhoneWindowManager、InputFilter 可能也需要处理

需要说明的是,当关闭新返回导航支持后,为了兼容旧的 API,Back Gesture 仍像以前一样发送 KEYCODE_BACK。当然这肯定是暂时的,后续系统肯定会强制使用该特性,到时候这个 Back Gesture 就再也不用发送 KEYCODE_BACK 了。

7. 注意和残留事项

  1. 本次变更跟 TargetSDKVersion 无关,运行在 13 上的 App 都需要思考是否收到影响、如何适配
  2. 直到 Android 13 最终版可预见型返回手势的动画才能生效:Settings > System > Developer options > Predictive back animations
  3. 新 SDK 返回 API OnBackInvokedDispatcher 中注册的 OnBackInvokedCallback 回调不是按照文档描述的逆序,而是只回调最后一个高优先级的 Callback
  4. Manifest 文件里 enableOnBackInvokedCallback 属性关闭的话,不要残留注册 OnBackInvokedCallback 的逻辑,不然新返回导航可能仍然有效
  5. Dialog 场景使用新版 SDK 返回 API 没有效果,原因未知
  6. View 监听 KEYCODE_BACK 的逻辑是否受影响,暂未实验
  7. 对于新 SDK 返回 API 的注册和销毁的时机可以选择:onCreate() + onDestroy(),onCreate() + onStop()、onResume() + onPause() 的组合亦可,但要注意是否会发生画面展示前的 Back Gesture 或 Back KeyButton 无法被监听以及画面进入后台了但 Callback 未被注销等问题。当然注册和注销的时机可依据需要的条件灵活选择,没有绝对的要求
  8. 使用 AndroidX API 方案要注意升级 AppCompat 到 1.6.0-alpha04,不然不生效
  9. 另外,采用 AndroidX API 方案但关闭了支持的话,Back Gesture 没有像 Back KeyButton 一样,只能收到 OnBackPressedCallback,没有 KeyEvent 回调,原因未知
  10. 对于某些场景下不希望 Callback 而希望系统处理的话,对于 SDK API 而言可以使用 unregister 方法注销该 Callback;对于 AndroidX API 而言可以将 Callback 状态置为 disabled

总结

制作了一张 Android 13 新返回导航适配流程图供大家快速查阅。

做个简单总结:

如果决定支持新返回导航即声明 enableOnBackInvokedCallback 为 true,之后需依据 App 集成了 SDK API 还是 AndroidX API 决定适配的方案。

  • SDK 方案的话需要引入新的 OnBackInvokedDispatcher 相关API,并留意 Activity、Dialog、Window、View 上现有的 Back 逻辑是否会收到影响,以及如何改造。当然需要判断运行版本,并为了兼容 13 之前的设备保留现有的 Back 逻辑
  • AndroidX 方案的话使用专属的OnBackPressedDispatcher API,AppCompat 库升级之后会自行完成内部的 SDK API 迁移

另外还需要留意上述章节提及的注意事项和残留事项。

当然如果没有余力适配,决定舍弃可预测型返回手势、OnBackInvokedDispatcher 新 API 以及 KEYCODE_BACK 等一系列变更,可以选择什么也不做。

但早在 13 之前,官方已推荐使用 AndroidX 的 OnBackPressedDispatcher 来取代 onBackPressed,13 花这么大精力完全废弃 onBackPressed 并向 AOSP 新增了 OnBackInvokedDispatcher 等系列 API。

从这个趋势来看,估计到 Android 14 这个新返回导航就会成为强制要求,开发者们当尽早适配才是!

参考

官方文档:

SDK API:

AndroidX API:

<think>我们正在解决的是在Android 13中修复返回处理逻辑错误,并且注意到`onBackPressed`方法已经被废弃的问题。 在Android 13(API 33)中,`onBackPressed()`方法被标记为废弃,推荐使用`OnBackPressedDispatcher`来管理返回事件。 解决方案: 1. 使用`OnBackPressedDispatcher`来注册自定义的回退逻辑。 2. 在Activity中,我们可以通过`getOnBackPressedDispatcher()`获取分发器,并添加回调。 步骤: a. 创建一个`OnBackPressedCallback`对象,并在其中实现我们的返回逻辑。 b. 根据条件(例如,是否允许返回)启用或禁用该回调。 c. 将此回调添加到`OnBackPressedDispatcher`中。 示例代码(在Activity中): ```java public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 创建OnBackPressedCallback,默认启用(表示我们要拦截返回事件) OnBackPressedCallback callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 在这里实现返回的逻辑 // 例如:如果当前在某个子界面,则先返回主界面;否则直接退出 if (shouldNavigateUp()) { navigateToMainScreen(); } else { // 如果要关闭应用,可以使用finishAffinity()或者finish() finishAffinity(); } } }; // 将回调添加到分发器中 getOnBackPressedDispatcher().addCallback(this, callback); } } ``` 注意:`OnBackPressedCallback`默认是启用的(传递true给构造函数)。我们可以通过`setEnabled(false)`来禁用,这样系统就会执行默认的返回操作(比如关闭当前Activity)。 另外,如果我们的应用中有多个组件都需要处理返回事件(比如Fragment),我们可以在每个Fragment中注册自己的回调,并且这些回调会按照添加的顺序反向执行(即最新添加的回调优先执行)。这样,我们可以确保当前显示的Fragment优先处理返回事件。 如果我们需要在Fragment中处理返回: ```java public class MyFragment extends Fragment { private OnBackPressedCallback callback; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 处理Fragment的返回事件 if (canGoBackInFragment()) { goBackInFragment(); } else { // 如果Fragment不需要处理,则禁用回调,这样Activity级别的回调就会执行 setEnabled(false); requireActivity().getOnBackPressedDispatcher().onBackPressed(); } } }; requireActivity().getOnBackPressedDispatcher().addCallback(this, callback); } @Override public void onDestroyView() { super.onDestroyView(); // 当Fragment销毁时,回调会被自动移除。也可以手动移除: // callback.remove(); } } ``` 这样,我们就可以在Android 13及更高版本中正确使用新的API来处理返回事件,同时兼容旧版本(因为`OnBackPressedDispatcher`在AndroidX中已经存在一段时间了,可以向后兼容)。 注意:在旧版本中,我们仍然可以重写`onBackPressed`方法,但是为了统一处理,我们可以使用`OnBackPressedDispatcher`来替代,这样在所有版本中都能保持一致的行为。 如果我们的最低支持版本较低,并且之前已经重写了`onBackPressed`,那么我们可以逐步迁移到新的API上。 另外,关于返回触发应用跳转到主界面的问题,我们可以在自定义的`handleOnBackPressed`中直接决定是关闭当前Activity还是导航到主界面,而不需要先跳转再关闭。 例如,我们想要在按返回时直接关闭应用,而不是跳转到主界面: ```java // 在Activity的callback中 OnBackPressedCallback callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 直接结束整个任务栈 finishAffinity(); } }; ``` 如果我们希望按返回时根据当前是否在主界面来决定是关闭应用还是返回主界面,我们可以这样: ```java OnBackPressedCallback callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { if (isTaskRoot()) { // 如果当前是任务栈的根Activity(即主界面),则直接关闭应用 finishAffinity(); } else { // 否则,执行默认的返回操作(关闭当前Activity,回到上一个Activity) // 注意:这里我们不能直接调用finish(),因为这样会导致回调被多次触发? // 实际上,我们可以调用父类的onBackPressed(虽然废弃,但在旧版本中可用)或者使用分发器执行默认操作 // 但是,在新API中,我们可以通过禁用回调并触发一次返回操作来实现 setEnabled(false); getOnBackPressedDispatcher().onBackPressed(); // 然后重新启用回调 setEnabled(true); } } }; ``` 但是注意,我们也可以使用另一种方式:在非根Activity时,我们并不拦截返回事件,而是让默认行为发生(即弹出当前Activity)。因此,我们可以根据当前Activity在任务栈中的位置动态调整回调的启用状态。 优化后的做法: 在Activity的`onCreate`中,我们初始化回调,并根据当前是否在根位置设置启用状态: ```java callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 只有在根Activity时才执行自定义逻辑(直接关闭) finishAffinity(); } }; // 设置初始状态:如果当前不是根Activity,则禁用这个回调(让系统默认行为生效) callback.setEnabled(isTaskRoot()); // 然后,我们需要监听Activity栈的变化,因为当Activity栈变化时(比如有新的Activity压栈或者出栈),isTaskRoot()状态可能会改变 // 但是,实际上,对于Activity本身,当它处于任务栈中时,其isTaskRoot()状态在创建后不会再改变,除非任务栈被重新排列。 // 因此,我们可以在onResume中根据当前任务栈情况更新状态 ``` 在`onResume`中更新: ```java @Override protected void onResume() { super.onResume(); callback.setEnabled(isTaskRoot()); } ``` 这样,当Activity在栈顶且是根Activity时,回调被启用,我们自定义的关闭整个应用的操作就会执行;如果不是根Activity,则回调被禁用,按返回就会执行系统默认的返回操作(关闭当前Activity,回到上一个Activity)。 这样,我们就避免了按返回先跳转到主界面再关闭的问题。 总结步骤: 1. 使用`OnBackPressedDispatcher`和`OnBackPressedCallback`来处理返回事件。 2. 根据当前Activity在任务栈中的位置(是否是根Activity)来动态启用或禁用回调。 3. 在根Activity时,直接关闭整个应用(例如使用`finishAffinity()`);在非根Activity时,让系统处理返回事件(弹出当前Activity)。 这样,我们既解决了`onBackPressed`被废弃的问题,也修复了返回逻辑错误。 注意:如果应用中有多个Activity,并且每个Activity都需要处理返回,那么在每个Activity中都需要设置这样的回调,并且根据每个Activity的具体情况(比如是否允许返回,是否是根)来设置。 相关问题: 1. 如何检测当前Activity是否为任务栈的根? 2. `finishAffinity()`和`finish()`的区别是什么? 3. 在Fragment中如何处理返回事件? 4. 如何兼容旧版本的Android系统(低于API 33)? </think>### 修复安卓13返回逻辑及`onBackPressed`废弃问题解决方案 #### 一、问题核心 在 Android 13(API 33)中,`onBackPressed()` 方法已被废弃,替代方案是使用 **`OnBackPressedDispatcher`** 配合 **`OnBackPressedCallback`** 实现更灵活的返回逻辑控制[^1]。返回跳转主界面的问题通常由以下原因导致: 1. **返回栈管理错误**:Activity 启动模式(如 `singleTask`)导致重建根 Activity 2. **生命周期调用冲突**:`onBackPressed()` 废弃后未适配新 API 3. **任务栈清除不彻底**:未正确处理多 Activity 场景的返回逻辑 #### 二、完整解决方案 ##### 1. 替换废弃的 `onBackPressed()`(关步骤) ```java // MainActivity.java @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 创建自定义返回回调 OnBackPressedCallback callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { // 直接关闭整个任务栈 finishAffinity(); System.exit(0); // 确保完全退出 } }; // 注册回调到分发器 getOnBackPressedDispatcher().addCallback(this, callback); } ``` **数学表达原理**: 设返回事件处理函数为 $f(x)$,新机制通过: $$ \text{OnBackPressedDispatcher} \times \text{Callback} \rightarrow f(x) $$ 替代旧版: $$ \text{Activity.onBackPressed()} \rightarrow f(x) $$ ##### 2. 修正启动模式配置 ```xml <!-- AndroidManifest.xml --> <activity android:name=".MainActivity" android:launchMode="standard" <!-- 避免singleTask造成重建 --> android:excludeFromRecents="true"> <!-- 防止出现在最近任务列表 --> </activity> ``` ##### 3. 多Activity场景优化 ```java // 子Activity中关闭时 public void exitApp() { Intent intent = new Intent(this, MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); finishAffinity(); } ``` #### 三、兼容性处理(支持旧版Android) ```java // 兼容Android 13以下版本 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // 使用OnBackPressedDispatcher } else { // 传统方式(API 33以下) @Override public void onBackPressed() { finishAffinity(); } } ``` #### 四、调试与验证方法 1. **返回栈检查命令**: ```bash adb shell dumpsys activity activities ``` 2. **生命周期监控**: ```java override fun onBackPressed() { Log.d("BACKPRESS", "Legacy method called") // 传统方法调用检测 } ``` 3. **测试用例**: - ✔️ 直接按返回 → 立即退出无跳转 - ✔️ 从子页面返回 → 直接退出应用 - ✔️ 后台杀死进程 → 无残留Activity #### 五、核心原理说明 1. **启动模式冲突**: `singleTask` 模式导致返回时重建根 Activity: $$ \text{返回} \rightarrow \text{重建MainActivity} \rightarrow \text{finish()} $$ 2. **新事件分发机制**: `OnBackPressedDispatcher` 实现事件分发管道: $$ \text{返回事件} \rightarrow \text{Dispatcher} \xrightarrow{\text{优先级}} \text{Callback队列} $$ 3. **任务栈清除**: `finishAffinity()` 清除整个任务栈: $$ \text{TaskStack} = \{A_1,A_2,\dots,A_n\} \xrightarrow{\text{finishAffinity()}} \emptyset $$ --- ### 相关问题 1. 如何在 Fragment 中处理返回事件? 2. `finishAffinity()` 和 `finishAndRemoveTask()` 的区别是什么? 3. 如何实现按返回时先弹出确认对话框再退出? 4. Android 13 手势导航返回事件如何协调处理?[^1] 5. 多模块应用如何统一管理返回逻辑?
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TechMerger

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值