桌面小组件的圆角,在模拟器上一直是对的。
从 1.16.1 那次自绘磨砂玻璃开始,每次装机我都在模拟器上截图看一遍:卡片四角圆润,壁纸从缝里露出来,没问题。直到用户发来一张真机截图——「小组件怎么又变成直角了」。手机是澎湃,卡片方方正正地贴在桌面上。
模拟器绿了不等于修好了。这句话我这次是真的付出了代价才记住的。
前面那几版结论,全是错的
一开始的怀疑方向很自然:桌面不给卡片后面垫圆角,而是垫了一块直角白板。于是接下来几个版本一直在想办法「把这块板挡掉」:
- 1.19.1–1.21.2:往根背景压一层全透明颜色,指望桌面认为「这张卡自带底了」;
- 1.21.3:把
GlanceModifier.appWidgetBackground()挂上去。这是官方给的正确姿势——拆glance-appwidget的 AAR,LayoutSelectionKt.insertViewInternal填的 viewId 确实是0x01020000,也就是android.R.id.background,桌面判断「卡片自带底了吗」看的正是这个 id。
挂完还是直角。再去拆一层:RemoteViewsTranslatorKt.translateComposition 会先把组合根的 modifier 交给 createRootView,而那个方法只从链上摘 WidthModifier / HeightModifier,其余的一概不看;然后把我们的 Box 塞进 res/layout/root_match_match.xml 里的那个 ViewStub。最终的树长这样:
AppWidgetHostView
└── FrameLayout(@id/rootView) ← 无背景,桌面看的是这一层
└── Box(@android:id/background) ← appWidgetBackground() 只能挂到这儿GlanceAppWidget.update() 是 final,整个 Glance 没留任何能后处理 RemoteViews 的钩子,rootView 补不上。WidgetLayoutKt.createNode 写进 proto 的那个 NodeIdentity.BACKGROUND_NODE,grep 全 AAR 只有写侧、没有任何类读它。
结论:这条路是死的。但注意——这条调查本身没解释用户看到的直角,因为那时候我的抠圆角代码根本没执行过。
决定性的一步:别猜,把中间产物落盘数像素
转折点是一个很笨的办法。卡片渲染的输入是一张 App 自己画出来的位图(取壁纸色 → 采样 → 三次盒式模糊 → 放大 → 叠渐变和描边 → 抠圆角),既然怀疑圆角没抠掉,就把这张位图原样存出来看:
bitmap.compress(Bitmap.CompressFormat.PNG, 100, File(context.getExternalFilesDir(null), "glass.png"))adb pull 下来,转裸像素,数 alpha:
ffmpeg -y -v error -i glass.png -f rawvideo -pix_fmt rgba glass.rgb
python3 -c "d=open('glass.rgb','rb').read(); a=d[3::4]; \
print('全透明', a.count(0), '半透明', sum(1 for x in a if 0 < x < 255))"0 个。一个透明像素都没有。
也就是说:圆角从 1.16.1 起就从来没被抠掉过,那张位图一直是方的。用户看到的直角才是它本来的样子;模拟器上之所以圆,是因为 Pixel launcher 会把卡片子 View 按自己的圆角轮廓裁一遍——它替我遮丑遮了三个版本。
根因:在位图的 Canvas 上,DST_IN 是空操作
原来的写法是教科书标准答案——画完整卡片,再用 DST_IN 把圆角外面那圈擦掉:
val mask = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) }
canvas.drawPath(roundRectPath(bounds, corner), mask)我写了六种变体挨个试,把每一种的位图都存出来数透明像素:
| 写法 | 透明像素 |
|---|---|
PorterDuffXfermode(DST_IN) | 0 |
Paint.blendMode = BlendMode.DST_IN | 0 |
saveLayer 里面做 DST_IN | 0 |
PorterDuff.Mode.CLEAR(直接圆角) | 0(擦错了对象,擦的是圆角里面) |
CLEAR + 反向圆角路径 | 有 |
clipPath + drawColor | 0 |
在承载位图的 Canvas 上,DST_IN 就是个空操作。能写穿到透明的只有 PorterDuff.Mode.CLEAR。而 clipPath 也顶不上:它没有抗锯齿,并且 drawColor 完全无视 clip。
于是改成先构造圆角外面那一圈的路径,再拿 CLEAR 把它擦掉:
val outside = Path().apply {
addRect(bounds, Path.Direction.CW)
op(roundRectPath(bounds, corner), Path.Op.DIFFERENCE)
}
val mask = Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
canvas.drawPath(outside, mask)跑出来:106 个全透明像素 + 86 个半透明像素(1.5dp 那条边的抗锯齿)。圆角这次是真被抠出来了。
中间还摔了一次:我不需要这个直角
抠出透明之后,我担心「桌面既然会垫直角白板,那透明缺口不正好会被它填实吗」——这个担心是有过实测依据的。所以在 1.21.4/1.21.5 我做了件自以为是的事:在抠好角的那一层底下,把没调过色的糊壁纸整张铺回去,让圆角外面变成不透明的壁纸快照,桌面垫什么都露不出来。
真机截图回来是这样的:圆角很透、很好看,圆角外面一圈方方正正的边。
「直角变透明了,这个透明很不错,但是我不需要这个直角,透明应用到小组件。」
1.21.6 把那张垫底的快照整个撤掉了。圆角外面就留真透明,底下露出什么,是桌面的事,不是 App 该自作主张去糊的。「垫一层保平安」这种决定不该我替用户做。
顺带记下来的几条
install -r之后小组件不会自己重画,桌面还留着上一版的RemoteViews。实测踩过:改完模糊参数装上去,截图和改之前逐字节相同,看着像代码没生效。得拉起 App 进程、点卡片上的「刷新」,再看logcat | grep SessionWorker确认跑了一轮。- 玻璃位图缓存是进程内的
LruCache,想让诊断代码再跑一遍得先am force-stop。 - 量卡片尺寸别信
uiautomator dump的 bounds:卡片刚重画那几秒,a11y 树会停在上一版。用 ffmpeg 逐像素扫。注意 PPM 头里有三个换行,切错了整列会偏 4 字节。 - Glance 1.1.1 没有
blur也没有alpha修饰符,RemoteViews在 android-35/36 的android.jar里一个 blur 方法都没有。磨砂只能自己糊位图;而读壁纸像素在 Android 17 上会被READ_EXTERNAL_STORAGE拦,无权限时只有getWallpaperColors()可用。 - 验「真透明」在模拟器上是可以做的:量卡片内角上的像素,再量同一行卡片外面那圈壁纸像素,两者一致就是透了;一致不了就是被垫了。这次就是这么确认的。
最后
发布 1.21.6(https://github.com/qine233/typecho-app/releases/tag/v1.21.6),CI 绿。
回头看,这次真正浪费掉的是「用截图代替验证」的那三个版本。界面这个东西,眼睛看到的圆不等于数据是圆的——中间产物(那张位图)才是唯一不会撒谎的证据。以后碰到「明明改了却没生效」,第一反应应该是把它落盘、拖出来、数像素,而不是再截一张图安慰自己。
评论 0
还没有评论,来抢沙发