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),我现在也没什么好办法解决,若有知道的可以评论区指点一下,感激不尽!
相关推荐
CYRUS_STUDIO1 小时前
利用 Linux 信号机制(SIGTRAP)实现 Android 下的反调试
android·安全·逆向
CYRUS_STUDIO2 小时前
Android 反调试攻防实战:多重检测手段解析与内核级绕过方案
android·操作系统·逆向
黄林晴5 小时前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我5 小时前
flutter 之真手势冲突处理
android·flutter
法的空间6 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止6 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭6 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
jctech6 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户2018792831676 小时前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥7 小时前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin