跳到主要内容Android App 黑白化技术实践与方案分析 | 极客日志Kotlin大前端java
Android App 黑白化技术实践与方案分析
Android App 黑白化技术主要通过将 Paint 的饱和度设置为 0 来实现。文章分析了两种常见方案:一是直接对 DecorView 设置层类型,二是替换内容栏 FrameLayout 为自定义黑白化 View。前者无法处理独立 DecorView 的 Dialog 和 PopupWindow,后者虽能处理 Dialog 但仍无法覆盖 PopupWindow。为解决全局生效问题,提出了 Hook WindowManagerGlobal 的思路,通过反射获取所有 Window 的 View 树统一应用黑白化滤镜,从而实现包括弹窗在内的全界面黑白化效果。
山野来信1 浏览 前言
近期打开各大 App 会发现它们都做了黑白化处理,例如支付宝等应用设置了全局灰色调,表达对逝者的哀悼。作为开发者,我们来探索一下它从技术角度是如何实现的。
一、App 黑白化实现原理
1.1、修改 Canvas 的 Paint 实现黑白化
正常情况下,App 页面上的 View 都是通过 Canvas(画布)+ Paint(画笔)画出来的。Canvas 对应画布,Paint 对应画笔,两者结合,就能画出 View。
在 Canvas 上绘制 View 的时候,如果换一支色彩饱和度为 0 的 Paint(画笔),理论上就能画出黑白化的 View。
可以通过 ColorMatrix 将饱和度设置为 0:
val paint = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
paint.colorFilter = ColorMatrixColorFilter(cm)
上述代码新建了一支色彩饱和度为 0 的 Paint,接下来使用它去进行 View 的绘制,就能达到黑白化的效果。
测试示例:自定义黑白化 TextView 和 Button。
class GrayTextView(context: Context, attrs: AttributeSet) : TextView(context, attrs) {
private val paint by lazy {
val p = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
p.colorFilter = ColorMatrixColorFilter(cm)
p
}
override fun draw(canvas: Canvas?) {
canvas?.saveLayer(null, paint, Canvas.ALL_SAVE_FLAG)
super.draw(canvas)
canvas?.restore()
}
}
class GrayButton(context: Context, attrs: AttributeSet) : Button(context, attrs) {
private val paint by lazy {
val p = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
p.colorFilter = ColorMatrixColorFilter(cm)
p
}
override fun draw(canvas: Canvas?) {
canvas?.saveLayer(null, paint, Canvas.ALL_SAVE_FLAG)
super.draw(canvas)
canvas?.restore()
}
}
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:text="普通 TextView" />
<com.example.GrayTextView
android:text="黑白化 TextView" />
<Button
android:text="普通 Button" />
<com.example.GrayButton
android:text="黑白化 Button" />
</LinearLayout>
1.2、给 View 设置 Paint 实现黑白化
View 有一个 API setLayerType,可以用来开启图层渲染。
public void setLayerType(int layerType, Paint paint)
layerType:
LAYER_TYPE_NONE: 视图正常渲染。
LAYER_TYPE_HARDWARE: 硬件加速渲染。
LAYER_TYPE_SOFTWARE: 软件渲染,关闭硬件加速。
paint: 画笔配置,可设置颜色过滤。
class MainActivity : AppCompatActivity() {
private val paint by lazy {
val p = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
p.colorFilter = ColorMatrixColorFilter(cm)
p
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tvBlackAndWhite = findViewById<TextView>(R.id.tvBlackAndWhite)
val btnBlackAndWhite = findViewById<Button>(R.id.btnBlackAndWhite)
tvBlackAndWhite.setLayerType(View.LAYER_TYPE_HARDWARE, paint)
btnBlackAndWhite.setLayerType(View.LAYER_TYPE_HARDWARE, paint)
}
}
二、App 黑白化方案实践
上述都是对单个 View 进行处理,若要整个页面变成黑白化,需要找到当前 View 树一个合适的父 View,对他进行黑白化设置或替换为自定义黑白化 View。
Android 页面结构通常包含顶级 View DecorView,其中包含竖直方向的 LinearLayout,分为标题栏和内容栏(FrameLayout)。setContentView 就是将 View 添加到这个 FrameLayout 中。
2.1、方案一:对 DecorView 进行黑白化设置
- 直接在 Activity 中通过 Window 获取 DecorView。
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, paint)
建议创建一个 BaseActivity 基类处理,所有继承 BaseActivity 的 Activity 都会生效。
- 在 Application 中注册
registerActivityLifecycleCallbacks 回调。
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
val decorView = activity.window.decorView
decorView.setLayerType(View.LAYER_TYPE_HARDWARE, paint)
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
此方式所有 Activity 都会生效,整个页面呈现黑白化效果。
2.2、方案二:替换内容栏 FrameLayout 为黑白化 FrameLayout
利用 LayoutInflater 的创建过程,优先使用 mFactory2 或 mFactory 创建 View。Activity 可以复写 onCreateView 方法,将内容栏 FrameLayout 替换为自定义的黑白化 FrameLayout。
abstract class BaseActivity : AppCompatActivity() {
abstract fun getLayoutId(): Int
abstract fun initView()
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
try {
if ("FrameLayout" == name) {
val attributeCount = attrs.attributeCount
for (i in 0 until attributeCount) {
val attributeName = attrs.getAttributeName(i)
val attributeValue = attrs.getAttributeValue(i)
if ("id" == attributeName) {
val resId = Integer.parseInt(attributeValue.substring(1))
val idValue = resources.getResourceName(resId)
if ("android:id/content" == idValue) {
return GrayFrameLayout(context, attrs)
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return super.onCreateView(parent, name, context, attrs)
}
}
class GrayFrameLayout(context: Context, attrs: AttributeSet) : FrameLayout(context, attrs) {
private val paint by lazy {
val p = Paint()
val cm = ColorMatrix()
cm.setSaturation(0f)
p.colorFilter = ColorMatrixColorFilter(cm)
p
}
override fun draw(canvas: Canvas?) {
canvas?.saveLayer(null, paint, Canvas.ALL_SAVE_FLAG)
super.draw(canvas)
canvas?.restore()
}
override fun dispatchDraw(canvas: Canvas?) {
canvas?.saveLayer(null, paint, Canvas.ALL_SAVE_FLAG)
super.dispatchDraw(canvas)
canvas?.restore()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.parseColor("#4A4A4A")
}
setContentView(getLayoutId())
initView()
}
三、问题分析与进阶方案
3.1、现有方案的局限性
方案一问题:
当点击按钮弹出 Dialog 时,Dialog 并未黑白化。这是因为 Dialog 拥有独立的 DecorView,Activity 的 DecorView 设置不会影响 Dialog。
方案二问题:
Dialog 会黑白化(因为 Dialog 创建了新的 PhoneWindow,使用了类似的 View 结构),但 PopupWindow 不会黑白化。PopupWindow 并没有像 Activity 那样被系统特殊处理为特定的 View 容器结构。
3.2、新思路:Hook WindowManagerGlobal
Activity、Dialog、PopupWindow 或其他 Window 组件最终都要经过 Window 的添加流程,最终走到 WindowManagerGlobal.addView 方法。
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
synchronized (mLock) {
mViews.add(view);
try {
root.setView(view, wparams, panelParentView);
}
}
}
WindowManagerGlobal 是一个全局单例,其中 mViews 是一个集合,App 中所有的 Window 在添加的时候都会被它存起来。
我们可以 Hook 拿到 mViews 中所有的 View 然后对他们进行黑白化设置。具体实现思路如下:
-
反射获取 WindowManagerGlobal 实例:
val field = Class.forName("android.view.WindowManagerGlobal").getDeclaredField("sInstance")
field.isAccessible = true
val instance = field.get(null)
-
获取 mViews 列表:
val viewsField = instance.javaClass.getDeclaredField("mViews")
viewsField.isAccessible = true
val views = viewsField.get(instance) as ArrayList<*>
-
遍历并应用黑白化:
遍历 views 中的每个对象(通常是 ViewRootImpl),通过反射获取其对应的 RootView 或 DecorView,然后应用之前定义的 setLayerType 或自定义 Paint 滤镜。
这种方式理论上可以覆盖所有 Window 类型的 UI 元素,包括 Dialog、PopupWindow 以及未来的新组件。虽然实现较为复杂且涉及反射,性能开销需评估,但它是解决全局黑白化最彻底的方法。
四、总结
本文介绍了 Android App 黑白化的实现原理及两种主流方案:
- 实现原理:将 Paint 的饱和度设置为 0,然后进行 View 的绘制。
- 方案一:对页面的 DecorView 进行黑白化设置。优点是简单,缺点是 Dialog 和 PopupWindow 不生效。
- 方案二:替换页面的内容栏 FrameLayout 为黑白化 FrameLayout。优点是部分弹窗生效,缺点是 PopupWindow 仍不生效。
- 进阶方案:通过 Hook
WindowManagerGlobal 统一处理所有 Window 的 View 树,可实现全场景黑白化。
开发者可根据实际业务需求选择合适方案,若追求完美体验,建议研究 Hook 方案。
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online