Android 通过消息队列机制实现无侵入式 Telegram/酷安 风格主题切换揭示动画

前言

我之前在用 Telegram 的时候,无意中点击到了设置里的切换夜间按钮,然后被动画惊艳到了。当时我的 Android 技术实在有限,根本无法想象开发者是怎么做到日/夜间切换如此流畅且拥有动画的。我之前确实写过切换主题,但都是立即切换,毫无含金量,调个 recreate()setTheme() 就解决了。当时在我的认知里,切换主题是需要重建(recreate) Activity 的。既然重建,重建之前的 activity 的很多东西几乎都不能保留,所以我想了好久都没想到处理的思路,再加上懒,就索性放弃了。

探寻 TG 源码

就在前些时候,我心血来潮,想彻底研究研究这是怎么实现的,就往 GitHub 上查了查 Telegram,看看有没有相关源码参考。不看不知道,一看吓一跳,Telegram 的源码竟然是屎山!平常使用的定位源码方式在 Telegram 源码里根本用不了。

然后我只好用 LibChecker 来看一眼那个界面是属于哪个 Activity,然后定点分析。Telegram 的 Activity 并不多,很好找,看样子应该在 LaunchActivity 里,因为其他 Activity 实在和主题、设置不搭边。随即我打开了 ui 文件夹,寻找 LaunchActivity,但令我奇怪的是,文件夹里怎么还有其他几十个 Activity?我随便打开了一个,震惊我一整天。

先别管这么多了,打开 LaunchActivity 找找吧。结果映入我眼帘的前 200 行全是 import,麻了。查关键字,果然找到了关键的字段!

java 复制代码
private ImageView themeSwitchImageView;
private View themeSwitchSunView;
private RLottieDrawable themeSwitchSunDrawable;

定位到这些字段所处的位置,就可以开始研究了。代码很长,我就不贴过来占空间了,给个地址,大家可以去观摩下 [源代码],在第 6468 行开始(一共 8000 多行,实在太恐怖)。

具体思路就是:

  1. 代码开始通过从数组中提取参数,设置变量,并为动画准备UI。它获取 drawerLayoutContainer 的宽度和高度,根据 toDark 布尔值设置 darkThemeView 的可见性,并对 drawerLayoutContainer 进行快照(也就是类似截图),以便将其设置给 themeSwitchImageView
  2. 然后代码根据主题是否切换为暗色来设置 themeSwitchImageViewthemeSwitchSunView。然后将 themeSwitchImageView 设置为之前的位图快照,并使其可见。代码根据用户点击的位置和 drawerLayoutContainer 的尺寸计算出环形揭示动画的最终半径(计算当前点击的控件距离与应用窗口的欧氏距离,看谁大要哪个)。
  3. 使用 ViewAnimationUtils#createCircularReveal 创建一个 Animator 对象,该对象将在 drawerLayoutContainerthemeSwitchImageView 上执行环形揭示动画,具体取决于主题是否切换为暗色。动画的持续时间设置为 400 毫秒,使用 缓动插值器 实现平滑的动画效果。
  4. 最后任务发布到 UI 线程,以在延迟后设置导航栏颜色并检查系统栏颜色。延迟时间是根据主题是否切换为暗色来计算的。最后开始动画。

总结就是:

java 复制代码
if (toDark) {
    frameLayout.addView(themeSwitchImageView, 0, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
    themeSwitchSunView.setVisibility(View.GONE);
} else {
    frameLayout.addView(themeSwitchImageView, 1, LayoutHelper.createFrame(LayoutHelper.MATCH_PARENT, LayoutHelper.MATCH_PARENT));
    themeSwitchSunView.setTranslationX(pos[0] - AndroidUtilities.dp(14));
    themeSwitchSunView.setTranslationY(pos[1] - AndroidUtilities.dp(14));
    themeSwitchSunView.setVisibility(View.VISIBLE);
    themeSwitchSunView.invalidate();
}
java 复制代码
Animator anim = ViewAnimationUtils.createCircularReveal(toDark ? drawerLayoutContainer : themeSwitchImageView, pos[0], pos[1], toDark ? 0 : finalRadius, toDark ? finalRadius : 0);
  1. 切换到夜间前,ImageView 放在根 View (frameLayout)内层(也就是 index = 0, FrameLayout 中 View 的 index 越小越在内层),设置上位图快照(相当于对当前窗口进行截图),并且设置为 VISIBLE,然后对 次根 View (drawerLayoutContainer)施加动画效果(由 0finalRadius 逐渐扩散)并切换到夜间模式。
  2. 切换到日间前,ImageView 放在根 View 顶层,设置上位图快照,并且设置为 VISIBLE,然后直接对它施加动画效果(由 finalRadius0 逐渐缩小)并切换到日间模式。

其实不只是我,许多人看到了这种精妙的解决方式都觉得妙不可言。竟然还有这种实现方式的?

那我们应该怎么样才能把这种炫酷的环形揭示切换动画提取出来,给我们自己的 App 使用,或者做成一个库呢?我们可以看到,确实有一些厂商也使用了这种效果,比如酷安。

其他人的实现方式

我从 GitHub 上找到两款比较优质的库/实现(似乎做这种库的人比较少,stars 数都不多):

MaskAnim

[MaskAnim] 的作者也在掘金上写过类似的文章,指路 [安卓开发实现电报的主题切换动画效果]

它的思路是:自定义 MaskView,在 onDraw() 方法里手动实现此 View 的扩散和收缩流程。将 MaskView 动态添加于 DecorView,根据传入的 MaskAnimModel 是 EXPAND 还是 SHRINK 来判断 MaskView 扩散还是收缩,动画结束之后,将 MaskView 移除于 DecorView。

所以可以看到 MaskView 是永远处于顶层的,而且没有使用 ImageView,跟 Telegram 的实现思路还有点不同。这种方法其实不错,拓展性也高(如果你想要其他动画),而且支持 Compose!但是如果对自定义 View 不熟悉的话,实现 MaskView 会有些难度。

CircularRevealThemesChanger

[CircularRevealThemesChanger] 是根据 这篇文章 写的。

它的思路是:将屏幕截图传给另一个 Activity 处理,另一个 Activity 用和 Telegram 一样的 ViewAnimationUtils#createCircularReveal 方法处理完动画之后再 finish()。当然,在启动 Activity 和 finish Activity 的过程中添加了取消启动动画的 Flag。

这种方式确实有点拉了,为实现这么一个效果引入一个新 Activity 不太可取。

其他

还有的人是往自己 xml 里的根 ViewGroup 里定义一个 ImageView 然后实现动画,还有的人是直接通过 LayoutInflater.Factory2 直接将 xml 的 View 映射为自己定义的"傀儡" View。

实现方式属实有点多,但到底有没有稍微简单点的方式,比如:

kotlin 复制代码
view.setDayNightModeSwitcher()

一句代码就能实现几乎所有功能呢?

其实 MaskAnim 也基本上实现了一句代码实现功能,但我从 Telegram 实现角度上重新组织一下。

TG 方向实现思路

我们先捋一下我们需要哪些函数才能实现这个功能:

  1. 判断当前是夜间还是日间模式:

    java 复制代码
    AppCompatDelegate.getDefaultNightMode()
  2. 切换夜间或日间模式:

    java 复制代码
    AppCompatDelegate.setDefaultNightMode(
        AppCompatDelegate.MODE_NIGHT_YES // or AppCompatDelegate.MODE_NIGHT_NO
    )
  3. 对当前整个屏幕进行截图:

    kotlin 复制代码
    protected open fun Window.screenshot() = decorView.rootView.drawToBitmap()
  4. ...

第二个和第三个是比较重要的,因为我们在做切换动画的时候就指望这两个函数起作用。

⚠ 注意:Telegram 的实现方式并不是通过 recreate,具体可以参考源码实现。recreate 实现更直观。

收缩

假设收缩是切换至日间模式。按照普通思路,我们可以模拟出这样的伪代码:

kotlin 复制代码
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束

这种思路是完全没有错的,但是有一个地方很容易被忽略,设置成夜间模式有一个 recreate 的过程,让我们观察 AppCompatDelegate#setDefaultNightMode 方法调用栈:

bash 复制代码
AppCompatDelegate#setDefaultNightMode
↓
AppCompatDelegate#applyDayNightToActiveDelegates
↓
AppCompatDelegateImpl#applyDayNight
↓
AppCompatDelegateImpl#applyApplicationSpecificConfig
↓
AppCompatDelegateImpl#updateAppConfiguration
↓
ActivityCompat#recreate

在稍微有点深的嵌套中,最后调用了熟悉的 ActivityCompat#recreate 方法。那这个方法又干了什么呢,看看调用栈:

bash 复制代码
ActivityCompat#recreate
↓
Activity#recreate
↓
ActivityThread#scheduleRelaunchActivity
↓
ActivityThread#scheduleRelaunchActivityIfPossible
↓
ActivityThread#sendMessage
↓
ActivityThread.H#sendMessage
↓
Handler#sendMessage

可以看到,recreate 就是给 ActivityThread 的 内部类 H 发送了一个异步消息,要求执行 recreate 作业。通过观察得知,H 类其实就是特殊的 Handler(Looper.getMainLooper()),因为 ActivityThread 所处的线程就是主线程。

Handler 的工作原理如下:

  1. 使用 Handler 的 sendMessage()post() 方法,会将消息添加到 MessageQueue 中。
  2. Looper 不断从 MessageQueue 中检索消息。
  3. 当 Looper 检索到消息时,它会将其传递给相应的 Handler。
  4. Handler 的 handleMessage() 方法会处理消息。

众所周知,MessageQueue 是一个先进先出(FIFO)的队列,这确保如果向同一个 Handler 发送多个消息,则它们将按发送顺序处理。

这给了我们很好的思路,既然 recreate 发送了消息,假设该消息占用了消息队列的第一位,那我们占第一位的后面不就好了?而且,recreate 发送的是异步(Asynchronous)消息,不受同步屏障影响,所以基本上 recreate 是立即完成的,几乎不用考虑时间问题,我们可以在 recreate 后的新的 Activity 里执行动画。

让我们写一下新的伪代码:

kotlin 复制代码
// 此时 Activity 为 旧 Activity(夜间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToDayMode() // 切换至日间模式
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(日间模式)
	RootView.addView(ImageView) // 给根 View 添加最前端全屏 ImageView
	ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
	ImageView.animate() // ImageView 进行动画,动画半径由最大 radius 到 0,结束
}

这样就又完善了一步。但现在的问题是:RootView 到底选择哪个?我们需要在动画结束之后将 ImageView 移除,但此时 Activity 已经是新的了,里面的字段基本都被重建了,应该怎么办?

让我们再看看发送的 recreate 事件会在 handleMessage() 方法中执行什么样的调用栈:

less 复制代码
ActivityThread#handleRelaunchActivityLocally
↓
ClientTransactionHandler#executeTransaction
↓
...
↓
TransactionExecutor#performLifecycleSequence
↓
ActivityThread#handleRelaunchActivity
↓
ActivityThread#handleRelaunchActivityInner
↓
ActivityThread#performPauseActivity #1
↓
ActivityThread#callActivityOnStop #2
↓
ActivityThread#handleDestroyActivity #3
	↓
	ActivityThread#performDestroyActivity
↓
ActivityThread#handleLaunchActivity
↓
ActivityThread#performLaunchActivity
	↓                ↓
	Activity#attach  Activity#setTheme #5
↓
Instrumentation#callActivityOnCreate
↓
Activity#performCreate #4
↓
Activity#onCreate

很标准的 Pause → Stop → Destroy → Create → (Start → Resume)

这就是 handler#post 之后走向新 Activity 的原因,而且会走完 onResume() 方法。

看向 #5,这也是为什么 setTheme 必须要在 super.onCreate 前执行。

我们发现,Activity 重建后,不论是 context 还是 window,全部都被重建了(参考 Activity#attach),唯一坚挺的就是 DecorView。

为什么这么说呢?在相关源码中明明 mWindow 都重新赋值了,DecorView 有什么理由不跟着变化呢?让我们看看 PhoneWindow 的构造器:

java 复制代码
public PhoneWindow(Context context) {
    super(context);
    mLayoutInflater = LayoutInflater.from(context);
}
/**
 * Constructor for main window of an activity.
 */
public PhoneWindow(Context context, Window preservedWindow,
ActivityConfigCallback activityConfigCallback) {
    this(context);
    // Only main activity windows use decor context, all the other windows depend on whatever
    // context that was given to them.
    mUseDecorContext = true;
    if (preservedWindow != null) {
        mDecor = (DecorView) preservedWindow.getDecorView();
        mElevation = preservedWindow.getElevation();
        mLoadElevation = false;
        mForceDecorInstall = true;
        // If we're preserving window, carry over the app token from the preserved
        // window, as we'll be skipping the addView in handleResumeActivity(), and
        // the token will not be updated as for a new window.
        getAttributes().token = preservedWindow.getAttributes().token;
    }
    // Even though the device doesn't support picture-in-picture mode,
    // an user can force using it through developer options.
    boolean forceResizable = Settings.Global.getInt(context.getContentResolver(),
    DEVELOPMENT_FORCE_RESIZABLE_ACTIVITIES, 0) != 0;
    mSupportsPictureInPicture = forceResizable || context.getPackageManager().hasSystemFeature(
        PackageManager.FEATURE_PICTURE_IN_PICTURE);
    mActivityConfigCallback = activityConfigCallback;
}

Activity 中,走的是第二个构造器,可以看到 DecorView 是重复利用的。

⚠ 但请注意:这是 Android 7.0 及以上的源码,在 API 23 及之前,DecorView 是不重复利用的,PhoneWindow 只有第一个构造器!所以你在 API 23 及之前切换主题或者切换夜间模式的时候,会黑一下屏!所以我暂定把最低支持 API 设置为 24。

所以,我们就可以名正言顺的在 DecorView 中添加 ImageView 和删除 ImageView 了!而且 DecorView 位于当前 Activity 的最顶层,非常方便地就能实现全屏动画!

所以,我们又可以更新我们的伪代码:

kotlin 复制代码
// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(夜间模式)
    // 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
    // 非要调用最后也没效果
	DecorView.addView(ImageView) // 给 DecorView 添加最前端全屏 ImageView
	ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
	ImageView.animate(onEnd = {
        DecorView.removeView(ImageView)
    }) // ImageView 进行动画,动画半径由最大 radius 到 0,结束后删除 ImageView
}

一个收缩动画就结束了。

扩张

假设扩张是切换至夜间模式。

扩张与收缩其实是差不多的,要注意我们需要扩张的是 DecorView 中的 content 界面,可以通过 findViewById() 寻找这个 次根 View。

kotlin 复制代码
// 此时 Activity 为 旧 Activity(日间模式)
screenshot = Window.screenshot() // 先把之前的屏幕截图
Activity.switchToNightMode() // 切换至夜间模式,内部是 MainHandler
MainHandler.post { // 将下面的消息传递在 recreate 后面
    // 此时 Activity 为 新 Activity(夜间模式)
    // 几乎所有之前的字段都不能调用,比如 Window,Activity,因为重建了
    // 非要调用最后也没效果
	DecorView.addView(ImageView, 0) // 给 DecorView 添加最末端全屏 ImageView
	ImageView.setBitmap(screenshot) // ImageView 添加之前的 screenshot
	content = DecorView.findViewById(android.R.id.content) // 次根 View
	content.animate(onEnd = {
        DecorView.removeView(ImageView)
    }) // content 进行动画,动画半径由 0 到最大 radius,结束后删除 ImageView
}

效果和 Telegram 是一模一样的,如果感兴趣,你可以调慢动画速度,然后在 Telegram 切换主题时滑动一下窗口试试,但内部实现方法还是有点差异。

通过这思路,你不仅可以实现日间夜间的切换,也可以实现主题的切换,只需要把这些相关代码包裹在 view 的 setOnClickListener()setOnTouchListener() 里就可以了!

定位

我们更希望在点击的位置进行环形的扩张与收缩,这时候就需要当前点击位置的 XY 值。

这个就比较常见了:

kotlin 复制代码
override fun onTouch(v: View, event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
            // Do not use event.rawX and event.rawY
            // It is not accurate in floating window mode
            // x = event.rawX
            // y = event.rawY
            v.getLocationInWindow(locationInWindow)
            x = event.x + locationInWindow[0]
            y = event.y + locationInWindow[1]
            if (DEBUG) {
                Log.d(TAG, "onTouch: x = $x, y = $y")
            }
        }
    }
    return false
}

正如注释所说,不要去直接使用 rawXrawY。这两个字段在全屏模式确实是准确的,但悬浮窗模式就不准了。

Logcat 观察

  1. Activity#recreate 后执行 Handler#post 直接到达 onResume()

  2. DecorView 在 Activity 重建过后依然保持内存地址不变,而 Activity 和 Window 发生了变化:

整理成库

我已经将这些整理成了一个 CircularRevealSwitch 库,欢迎指点!

使用也很简单,实现最基础的功能,只需要:

关键代码在这里:[CircularRevealSwitch.kt]

目前存在的问题

  1. 使用 HarmonyOS 和 HyperOS(MIUI) 动画一切正常,经过观察,DecorView 是一直无变化的,符合预期。但是使用 Android Emulator 测试的时候,不知道为什么 DecorView 一直在变化,导致动画失效。
  2. 部分手机 DecorView 显示的速度会比后添加的 ImageView 快,导致闪屏问题(比如 HarmonyOS),我现在也没什么好办法解决,若有知道的可以评论区指点一下,感激不尽!
相关推荐
CheungChunChiu34 分钟前
Android10 rk3399 以太网接入流程分析
android·framework·以太网·eth·net·netd
木头没有瓜1 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠0071 小时前
springboot 上传图片 转存成webp
android·spring boot·okhttp
江上清风山间明月2 小时前
flutter bottomSheet 控件详解
android·flutter·底部导航·bottomsheet
Crossoads3 小时前
【汇编语言】外中断(一)—— 外中断的魔法:PC机键盘如何触发计算机响应
android·开发语言·数据库·深度学习·机器学习·计算机外设·汇编语言
sunphp开发者4 小时前
黑客攻击网站,篡改首页问题排查修复
android·js
我又来搬代码了5 小时前
【Android Studio】创建新项目遇到的一些问题
android·ide·android studio
ggs_and_ddu9 小时前
Android--java实现手机亮度控制
android·java·智能手机
zhangphil14 小时前
Android绘图Path基于LinearGradient线性动画渐变,Kotlin(2)
android·kotlin
watl015 小时前
【Android】unzip aar删除冲突classes再zip
android·linux·运维