安卓进阶涨薪训练营 ,让一部分人先进大厂
大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。
详情见文章: 没错!皇叔开了个训练营
在一名 Android 程序猿的职业生涯中,大概率会与设计狮有过这样的讨论:
🦁:“我要这个毛玻璃效果”
🐒:“这个效果实现不了”
🦁(掏出iPhone):“你看人家苹果都有,你怎么做不了?😒”
🐒:“iOS 可以,Android 真的做不了,童叟无欺😭”
这个让设计师心心念念的视觉效果,就是背景模糊。
iOS 8 加入的 UIVisualEffectView 、CSS 中的 backdrop-filter 以及 Flutter 中的 BackdropFilter 类,都可以实现这个效果,只有 Android 一直没有支持该能力。
在 Android 领域内,背景模糊效果有两个不同场景,需要做一下区分:
Window 级的背景模糊,多年以来各手机厂商都有自己的实现方案。而 Android 12 里,AOSP 对SurfaceFlinger 进行了重构,GPU 合成的部分也使用 Skia 进行渲染,同时对跨 Window 的背景模糊做了官方的支持[1],并 public 了相关的 API。
Android 12 支持的Window背景模糊
(模糊内容属于背后的窗口)
而本文要讨论的是后者—— View 级背景模糊 的实现方式。
iOS 上的 View 级背景模糊效果
(模糊内容属于同一窗口内背后的控件)
笔者整理了现在开源库中的两类解决方案, 外加酷派团队调研出的两种方案 ,共四种写法,供大家参考。
如果要从应用侧实现这个效果,最重要的一个步骤是 获取模糊控件背景的内容 ,目前 Github 上的相关框架,大概分为两个方案:
方案一:找到背景控件、调用draw
代表框架:
500px/500px-android-blur、Dimezis/BlurView、mmin18/RealtimeBlurView,这三个框架的⭐️数量均为 2.7k 左右,接受度比较高。
创建一个链接到 Bitmap 的离屏Canvas,在模糊控件绘制之前,将下层布局手动绘制到这个 Canvas 里,这样在 Bitmap 里就拿到了控件背景内容。
其中:500px-android-blur 的做法是手动指定背景的布局,在模糊控件 onDraw 的时候,对指定的下层布局进行绘制。这种做法实现起来比较简单,但每次都需要手动指定下层布局,而且模糊控件不能包含在该布局里,缺少灵活性。
// 手动指定下层布局 blurringView.setBlurredView(blurredView);而 Dimezis/BlurView、mmin18/RealtimeBlurView 的解法则更加灵活,下层布局不用特别指定,直接使用rootView(一般为DecorView)。在模糊控件 onPreDraw 的时候,将rootView绘制到离屏Canvas。
但 rootView 并不是 下层布局 ,因为模糊控件也包含在内。这两个框架使用比较取巧的办法解决了这个问题:在 draw(Canvas) 方法里,判断如果是离屏 Canvas 在绘制,则跳过自身绘制。
获取背景内容后,剩下的步骤则是对 Bitmap 进行模糊处理并绘制,由于比较简单就不赘述了。
方案效果(以500px-android-blur为例)
方案缺点主要是额外的性能开销。
方案二:Canvas GL Functor
代表框架:HokoFly/HokoBlurDrawable
这个框架使用起来非常简单,不需要做额外的设置,只需要给View设置一个background即可。
final BlurDrawable blurDrawable = new BlurDrawable; view.setBackgroundDrawable(blurDrawable);调查了源码,它也没有把下层布局再次绘制,那它是如何获取到背景内容的?
秘密在于这个隐藏方法: RecordingCanvas.callDrawGLFunction2
* Records the functor specified with the drawGLFunction function pointer. This is * functionality used by webview for calling into their renderer from our display lists. * @param drawGLFunction A native function pointer * @hide * @deprecated Use { @link #drawWebViewFunctor(int)} @Deprecated public void callDrawGLFunction2 ( long drawGLFunction) { nCallDrawGLFunction(mNativeCanvasWrapper, drawGLFunction, null );这个隐藏的方法看起来比较陌生,因为它不是设计给 App 使用的。它的作用是将外部的 OpenGL 方法链接到 Canvas 的绘制流程里,目前官方的使用场景是 Android 的 WebView。
WebView 使用自己的 OpenGL 方法对网页进行渲染,再调用这个方法,将结果链接到你的 App Window 里。
这刚好能解释,为什么 WebView 使用独立的渲染机制,但不需要使用 SurfaceView 的独立 layer 也能显示到屏幕上。
经过一番调查发现,参数 drawGLFunction ,是 Android Native 层的一个通用函数指针,结构如下:
Functor {} virtual ~Functor {} virtual status_t operator ( )( int /*what*/ , void * /*data*/ ) { return OK; }在 UI 线程调用 callDrawGLFunction2 方法后,只是设置了函数指针,并没有起到绘制效果。真正的绘制逻辑,发生在 RenderThread 里:
// File: frameworks/base/libs/hwui/pipeline/skia/GLFunctorDrawable.cpp void GLFunctorDrawable::onDraw(SkCanvas* canvas) { // 省略部分代码GLuint fboID = 0 ; SkISize fboSize; GetFboDetails(canvas, &fboID, &fboSize);
// 省略部分代码:初始化GLContext、判断离屏Layer等 …… DrawGlInfo info; info.clipLeft = clipBounds.fLeft; info.clipTop = clipBounds.fTop; info.clipRight = clipBounds.fRight; info.clipBottom = clipBounds.fBottom; info.isLayer = fboID != 0 ; info.width = fboSize.width; info.height = fboSize.height; mat4.getColMajor(&info.transform[ 0 ]); info.color_space_ptr = canvas->imageInfo.colorSpace;
// 省略部分代码:绑定FBO、设置GL环境 …… if (mAnyFunctor.index == 0 ) { std::get< 0 >(mAnyFunctor).handle->drawGl(info); } else { // 这里会调用到函数指针Functor (*(std::get< 1 >(mAnyFunctor).functor))(DrawGlInfo::kModeDraw, &info); } // 省略部分代码 …… }
看到这里,为防止有读者不了解 hwui,补充一些前提知识:
我们都知道 Android 的 View 系统,在底层是使用 Skia 图形库进行渲染,而连接 View 与 Skia 的组件便是 libhwui。App 界面的绘制指令,最终都通过 hwui 库,在 RenderThread 中得以执行。
Skia 的 SkDrawable 结构,与 Android 的 Drawable 类似,都是在 onDraw(canvas) 方法中执行绘制指令,实际上后者也是对前者的一个效仿设计。
在 hwui 库中,一共有这几种 SkDrawable 类型:
而方案二,正是使用了 GLFunctorDrawable 。
回到这个库中,上面的GLFunctorDrawable,会调用上层设置过来的 functor方法,并将此时的 DrawGlInfo 传递过去。
我们来看看这个库的 Functor 真正做了些什么:
经过 jni 的多次中转,Functor 最终调用到了上面的 Java 层逻辑。与一般的 Java 层逻辑不同,图中的代码实际上运行在 RenderThread 中,而且拥有 GLFunctorDrawable 已经设置好的 OpenGL 上下文。
这里的逻辑大概分为三步: 获取当前屏幕内容、进行 X/Y 轴两次模糊运算、放大并叠加颜色 。
最关键的代码就是这行 glCopyTexSubImage2D ,它可以将当前屏幕已绘制内容进行区域拷贝[3]。由于这个代码的执行在背景内容绘制和控件内容绘制之间,这样便 获取到了控件的背景内容 。
总结
这个方案使用 GLFunctorDrawable 的机制,将自己的 OpenGL 指令嵌入到 RenderThread 每一帧的绘制中,获取背景内容,做模糊并上屏。
优点
缺点
当然,这个方案的缺点也十分明显,导致它无法在 app 里商用。
callDrawGLFunctor2 是一个 hide 方法,其在不同 Android 版本都有不同实现,框架本身做了多平台兼容。
但在 Google 对 hide 方法的态度及采取的措施面前,这个做法显得不可持续。
首先,Android 11 开始采取了更严格的反射限制,框架使用者需要额外去处理限制突破逻辑。
其次, callDrawGLFunctor2(long functor) 是一个废弃方法,在 Android 12 开始被移除。
在 Android 12 中,被换成 drawWebViewFunctor(int functor) ,不仅 functor 换了结构,还必须同时支持 OpenGL 和 Vulkan 两种实现。
这个方案的兼容成本已经非常高了, 框架作者也没有继续进行维护,目前该框架在 Android 12 上无法使用。
在 hwui 的 pipeline 里,如果这块内容被绘制到了一块离屏 buffer 再上屏,那么这里的 GLFunctor 便无法获取到控件背后的内容。
这是因为,在 hwui 每一帧绘制开始之前,会先把离屏的 Layer 先渲染完成得到结果,再把这些结果当做图像资源,在这一帧参与绘制。
需要离屏 buffer 的场景有这几种: 设置小于 1 的 alpha 、 需要 clip 的 Functor 、 有拉伸或者RenderEffect 效果(Android 12 添加) 等,比较常见。
// File: frameworks/base/libs/hwui/RenderProperties.h bool promotedToLayer const { return mLayerProperties.mType == LayerType::None && fitsOnLayer && // 是functor且有clip、animation、translation等情况。 (mComputedFields.mNeedLayerForFunctors || // 设置了RenderEffect(Android 12新增) mLayerProperties.mImageFilter != nullptr || // 当前有拉伸效果(Android 12新增) mLayerProperties.getStretchEffect.requiresLayer || // 当前View设置了alpha,且hasOverlappingRendering为true (!MathUtils::isZero(mPrimitiveFields.mAlpha) && mPrimitiveFields.mAlpha < 1 && mPrimitiveFields.mHasOverlappingRendering)); }所以当 GLFunctor 被离屏渲染时, 它会提前执行,此时便无法获取到它背后的内容 。也就是说这种方案, 无法对模糊控件设置 alpha 或者切圆角 ,这点也在框架的 issues 里得到了印证:
自研方案
方案三:扩展 Canvas.saveLayer
笔者发现,Flutter 中的 BackdropFilter 类也能实现效果,其也使用 Skia 作为渲染引擎。
BackdropFilter 这个 Dart 层的 Widget,经过 SceneBuilder 的中转,最终映射到 native 层的 BackdropFilterLayer 类:
它并没有特殊的绘制逻辑,只是在绘制 children 内容之前,调用了一个 saveLayer 操作。 难道 Flutter仅仅通过 saveLayer ,就实现了背景模糊?
Android中也有 Canvas.saveLayer 这个API,其作用是创建一个新的Layer,后续的绘制均发生在这个新的Layer里,绘制完成后将结果再一起上屏。所以这个方法开销比较大,非必要不建议使用。
更关键的是,它并不支持背景模糊功能。
看来红框中的这一行代码是关键。调查 SaveLayerRec 发现,它是 skia 引擎中的结构体,大致结构如下:
enum SaveLayerFlagsSet { kInitWithPrevious_SaveLayerFlag = 1 << 2 , //!< initializes with previous contents };struct SaveLayerRec { SaveLayerRec( const SkRect* bounds, const SkPaint* paint, SaveLayerFlags saveLayerFlags = 0 ) : fBounds(bounds) , fPaint(paint) , fSaveLayerFlags(saveLayerFlags) {} SaveLayerRec( const SkRect* bounds, const SkPaint* paint, const SkImageFilter* backdrop, SaveLayerFlags saveLayerFlags) : fBounds(bounds) , fPaint(paint) , fBackdrop(backdrop) , fSaveLayerFlags(saveLayerFlags) {} /** hints at layer size limit */ const SkRect* fBounds = nullptr ; /** modifies overlay */ const SkPaint* fPaint = nullptr ; /** * If not null, this triggers the same initialization behavior as setting * kInitWithPrevious_SaveLayerFlag on fSaveLayerFlags: the current layer is copied into * the new layer, rather than initializing the new layer with transparent-black. * This is then filtered by fBackdrop (respecting the current clip). */ const SkImageFilter* fBackdrop = nullptr ; /** preserves LCD text, creates with prior layer contents */ SaveLayerFlags fSaveLayerFlags = 0 ; };
这里的 SkImageFilter 是 Skia 中可以实现图形效果的工具类,常见的 filter 有:Blur、ColorFilter、Matrix、XferMode 等等。
Skia 在很早就支持了这个能力,在 Android 12 中,谷歌在上层封装了RenderEffect类,第一次将其开放给上层调用[4]。
看下真正执行模糊运算的地方:
void SkCanvas::internalSaveLayer( const SaveLayerRec& rec, SaveLayerStrategy strategy) { // 省略代码 …… // If we have a backdrop filter, then we must apply it to the entire layer (clip-bounds) // regardless of any hint-rect from the caller. skbug.com/8783 if (rec.fBackdrop) { bounds = nullptr ; } // 两种情况下会绘制背景内容 bool initBackdrop = (rec.fSaveLayerFlags & kInitWithPrevious_SaveLayerFlag) || rec.fBackdrop; // 省略代码 …… if (initBackdrop) { DrawDeviceWithFilter(priorDevice, rec.fBackdrop, newDevice.get, { ir.fLeft, ir.fTop }, fMCRec->fMatrix.asM33); } // 省略代码 …… }void SkCanvas::DrawDeviceWithFilter(SkBaseDevice* src, const SkImageFilter* filter, SkBaseDevice* dst, const SkIPoint& dstOrigin, const SkMatrix& ctm) { // 省略代码 …… // 截取当前已经绘制的内容 auto special = src->snapSpecial(backdropBounds); if (!special) { return ; } // 省略代码 … SkIPoint offset; // 使用指定的filter进行处理 special = as_IFB(filter)->filterImage(ctx).imageAndOffset(&offset); if (special) { offset += layerInputBounds.topLeft; SkMatrix dstCTM = toRoot; dstCTM.postTranslate(-dstOrigin.x, -dstOrigin.y); dstCTM.preTranslate(offset.fX, offset.fY); // 将处理结果进行绘制 dst->drawSpecial(special.get, dstCTM, sampling, p); } // 省略代码 …… }
结合注释与代码得知,有两个组合可以实现背景模糊效果:
区别在于,前者会将整个 Canvas 的内容都进行处理,然后 clip 到相应区域;而后者只会截取指定区域的内容,另外也不会立即处理,而是选择在新 layer 上屏的时刻统一处理。
Flutter 使用的是第一个组合。笔者两个方案都进行了尝试,本文与Flutter保持一致,讨论第一种组合。
思路解析
看完 Flutter,我们再来看看 Android 里的 saveLayer 逻辑,经过一些中转,它最终调用到了这里
// File: frameworks/base/libs/hwui/SkiaCanvas.cpp int SkiaCanvas::saveLayer( float left, float top, float right, float bottom, const SkPaint* paint, SaveFlags::Flags flags) { const SkRect bounds = SkRect::MakeLTRB(left, top, right, bottom); // 这里只用到了 bounds, paint, layerFlags 三个参数 const SkCanvas:: SaveLayerRec rec (&bounds, paint, layerFlags(flags)) ; return mCanvas->saveLayer(rec); }可以看到,Android 直接忽略了后面两个参数,并没有提供任何暴露途径。
那我们的思路也就很简单了:
实机效果演示
优点
这个方案我提交到了AOSP[5],谷歌工程师给了两个反馈:
第一条反馈与实际表现不符合,关于性能是否符合要求,需要进一步的调查和实验。
但第二条确实如此,所以还需要继续找寻新的解法。
方案四:修改libhwui,增加模糊计算
View 的 alpha 发生改变,其实是设置的 RenderNode.setAlpha 方法。
方案二与方案三,由于都使用了 Canvas 的接口,所以无论是重写 View.onDraw 方法,还是封装 Drawable ,这个调用指令都在该 View 的 RenderNode 内部。这样当 alpha 变化时,就无法获取到背景内容。
除了 alpha 外,这次也打算将所有的边界场景一次考虑清楚:View 的 transform、动画、clip 等。目前能想到的方案,是让背景模糊逻辑脱离 RenderNode 。
参照前文, RenderNode 的真正绘制,是在 RenderNodeDrawable 中,我们可以新定义一个 BackdropFilterDrawable 类型,与其平级。
将 BackdropFilterDrawable 的绘制顺序,提前到 RenderNodeDrawable 之前即可。
BackdropFilterDrawable 类的关键逻辑如下:
void BackdropFilterDrawable::onDraw(SkCanvas* canvas) { // 对后面内容进行截图(并不会创建新的buffer),此截图为Canvas完整截图。 auto backdropImage = canvas->getSurface->makeImageSnapshot; // 从target RenderNode那里,同步properties,无论它是否在做动画、缩放、是否有clip等,都进行同步。计算结果保存到 mImageSubset 里,这是我们上层RenderNode真正的可见区域。 if (!prepareToDraw(canvas, properties, backdropImage->width, backdropImage->height)) { // 当返回false的时候,说明不可见,则我们也跳过绘制。 return ; }auto imageSubset = mImageSubset.roundOut; // 将截图里的上层区域进行filter处理。 backdropImage = backdropImage->makeWithFilter(canvas->recordingContext, backdropFilter, imageSubset, imageSubset, &mOutSubset, &mOutOffset); // 将filter结果进行绘制。 canvas->drawImageRect(backdropImage, SkRect::Make(mOutSubset), mDstBounds, SkSamplingOptions(SkFilterMode::kLinear), &mPaint, SkCanvas::kStrict_SrcRectConstraint); }
优点
与方案三相同,优势在于性能和效果。
这个方案我也提交到了AOSP[6],目前状态为待 Review。感兴趣的可以编译看下效果。
方案对比
我们在酷派COOL 20s 5G上,将四种方案进行横向对比。
这台机器配置为天玑700、6GB内存、128GB存储、1080p 90Hz的屏幕。
使用perfetto抓取的trace,来衡量和计算每种方案在 Choreographer.doFrame 和 RenderThread 分别消耗的时间。
在perfetto里可以直观地看到平均耗时
抓取无模糊效果的耗时,作为基准指标。分别为:doFrame 1.588ms, RenderThread: 3.485ms。
最终对比结果如下:
方案一综合开销最高,后面三个方案的 doFrame 耗时,与基准耗时的差异在误差范围内,几乎没有引入额外的计算。
综合来看, 方案四 各方面表现都很优秀,酷派在自研的COOLOS里,已经有多处采用了它。
后记
经过这样一番调研,笔者的有很多感悟和提升,其中感触最深的是:如果对一块领域感兴趣,但网络和社区没有更好解法时,就自己读源码吧。
在这个能力的调研过程中,有非常多有意思的技术问题,每一项都值得深入去探讨和研究。
比如:
由于篇幅限制,本 文不再展开,有机会可以开些续文,详细讲讲。
感兴趣的读者,欢迎评论区跟我们一起讨论!
参考链接
为了防止失联,欢迎关注我防备的小号
微信改了推送机制,真爱请星标本公号 👇 返回搜狐,查看更多
责任编辑: