记录两种方案
1、使用 PorterDuff.Mode.CLEAR 绘制,挖洞处理(高亮原View,但是不支持高斯模糊)


如图看到,其实是在rootView上面绘制了一个半透明蒙层,然后动态获取到高亮View的位置跟大小,对其进行图片混合绘制,将指定区域镂空擦除,这样就凸显出需要高亮的区域
import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.RectF import android.util.AttributeSet import android.util.Log import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.animation.AccelerateDecelerateInterpolator import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import com.blankj.utilcode.util.GsonUtils /** 高亮引导 */ class HighlightGuideView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { companion object { private const val TAG = "HighlightGuideView" private const val ANIMATION_DURATION = 200L private var isShow = false @JvmStatic fun show( context: Context, highlightView: View, radius: Float = context.resources.getDimension(R.dimen.common_dp_28), onDismiss: (() -> Unit) ) { if (isShow) return isShow = true HighlightGuideView(context).show( highlightView, FragmentGuideBinding.inflate(LayoutInflater.from(context)).root, radius, onDismiss ) } } private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor(context, R.color.main_highlight_guide_bg) style = Paint.Style.FILL } private val highlightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.TRANSPARENT xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) } private var highlightRect = RectF() private var highlightView: View? = null private var cornerRadius = 0f private var onDismissListener: (() -> Unit)? = null private var bitmap: Bitmap? = null private var myCanvas: Canvas? = null init { setWillNotDraw(false) setLayerType(LAYER_TYPE_SOFTWARE, null) (layoutParams as? LayoutParams)?.gravity = Gravity.CENTER } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) if (w <= 0 || h <= 0) { bitmap?.recycle() bitmap = null myCanvas = null return } bitmap = createBitmap(w, h) bitmap?.let { myCanvas = Canvas(it) invalidate() } Log.i(TAG, "onSizeChanged width $w height $h") } /** * 显示高亮引导 * @param highlightView 要高亮的View * @param cornerRadius 圆角半径(当shape为ROUNDED_RECT时有效) * @param onDismiss 关闭回调 */ fun show(highlightView: View, contentView: View?, radius: Float, onDismiss: (() -> Unit)) { this.highlightView = highlightView this.cornerRadius = radius this.onDismissListener = onDismiss val rootView = highlightView.rootView if (rootView !is ViewGroup) { Log.w(TAG, "rootView not view group") return } rootView.findViewWithTag<HighlightGuideView>(TAG)?.let { rootView.removeView(it) } tag = TAG contentView?.let { addView(it) } rootView.addView( this, ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) ) calculateHighlightRect(highlightView) animateShow() Log.i(TAG, "show") } private fun calculateHighlightRect(highlightView: View) { val location = IntArray(2) highlightView.getLocationOnScreen(location) highlightRect.set( location[0].toFloat(), location[1].toFloat(), location[0] + highlightView.width.toFloat(), location[1] + highlightView.height.toFloat() ) Log.i(TAG, "calculateHighlightRect ${GsonUtils.toJson(highlightRect)}") } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (myCanvas == null) { Log.w(TAG, "myCanvas is null") return } if (bitmap == null) { Log.w(TAG, "bitmap is null") return } myCanvas?.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) myCanvas?.drawRoundRect(highlightRect, cornerRadius, cornerRadius, highlightPaint) bitmap?.let { canvas.drawBitmap(it, 0f, 0f, null) } } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { if (!highlightRect.contains(event.x, event.y)) { dismiss() } } MotionEvent.ACTION_CANCEL -> { dismiss() } } return true } fun dismiss() { animateDismiss() } private fun animateShow() { alpha = 0f animate() .alpha(1f) .setDuration(ANIMATION_DURATION) .setInterpolator(AccelerateDecelerateInterpolator()) .start() } private fun animateDismiss() { animate() .alpha(0f) .setDuration(ANIMATION_DURATION) .setInterpolator(AccelerateDecelerateInterpolator()) .withEndAction { (parent as? ViewGroup)?.removeView(this) onDismissListener?.invoke() }.start() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() Log.i(TAG, "onDetachedFromWindow") isShow = false bitmap?.recycle() bitmap = null myCanvas = null } }
private fun showHighlightView() { mBinding.refreshLayout.getHighlightView()?.let { HighlightGuideView.show(this, it) { } } }
如果要对窗口高斯模糊,那需要在rootView上进行绘制(activity共享一个window),会有很多麻烦 IllegalArgumentException: Software rendering doesn't support hardware bitmaps
2、使用Dialog全屏覆盖
使用弹框实现,因为DialogFragment是独立window,所以直接使用系统级高斯模糊
每次绘制时 CPU 内存,将图片数据打包好(也就是dequeueBuffer),然后上传到 GPU,GPU具体渲染,最终提交到屏幕设备
因为Dialog中window是在GPU渲染时模糊,最终提交到屏幕设备,所以不需要另外去处理模糊(Dialog中直接访问 Buffer Queue,activity需要截图绘制)

import android.content.res.Configuration import android.os.Bundle import android.util.Log import android.view.View import android.view.WindowManager import android.view.animation.AccelerateDecelerateInterpolator import androidx.annotation.IntDef import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE import androidx.core.view.updateLayoutParams import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager /** * 高亮引导弹框 * @param sourceList 需要高亮的view * @param type 引导类型(目前支持首页+预览页) */ class GuideDialog(private val sourceList: MutableList<View>, private val type: Int) : DialogFragment(R.layout.fragment_guide) { private val mBinding by viewBinding(FragmentGuideBinding::bind) init { setStyle(STYLE_NORMAL, R.style.guide_dialog_theme) SharedPreferencesUtils.setGuideRecord(getGuideKey(type)) } companion object { private const val TAG = "GuideDialog" private const val PET_WALLPAPER_GUIDE_MAIN = "pet_wallpaper_guide_main" private const val PET_WALLPAPER_GUIDE_PREVIEW = "pet_wallpaper_guide_preview" private var isShow = false const val MAIN = 0 const val PREVIEW = 1 @IntDef(MAIN, PREVIEW) @Retention(AnnotationRetention.SOURCE) private annotation class GuideItemType private fun getGuideKey(@GuideItemType type: Int): String { return if (type == PREVIEW) { PET_WALLPAPER_GUIDE_PREVIEW } else { PET_WALLPAPER_GUIDE_MAIN } } @JvmStatic fun show(manager: FragmentManager, sourceView: View, @GuideItemType type: Int) { show(manager, mutableListOf(sourceView), type) } @JvmStatic fun show( manager: FragmentManager, sourceList: MutableList<View>, @GuideItemType type: Int ) { if (sourceList.isEmpty()) return if (isShow) return isShow = true if (SharedPreferencesUtils.isShowGuide(getGuideKey(type))) { GuideDialog(sourceList, type).show(manager, TAG) } } } private val mainItemView: View by lazy { LayoutGuideMainItemBinding.inflate(layoutInflater, mBinding.root, true).root } private val previewBtnView: View by lazy { LayoutGuidePreviewBtnBinding.inflate(layoutInflater, mBinding.root, true).root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Log.i(TAG, "onViewCreated") dialog?.window?.apply { setLayout( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT ) if (type == PREVIEW) { WindowCompat.setDecorFitsSystemWindows(this, false) WindowCompat.getInsetsController(this, decorView).apply { systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE hide(WindowInsetsCompat.Type.systemBars()) } } } initView() animateShow() } private fun initView() { setContent() mBinding.btnOk.setOnClickListener { click() } val highlightView = when (type) { PREVIEW -> previewBtnView else -> mainItemView } if (sourceList.isNotEmpty()) { updateItemView(sourceList[0], highlightView) } else { Log.w(TAG, "sourceList is empty") } mBinding.root.setOnClickListener { click() } } private fun setContent() { if (type == PREVIEW) { mBinding.tvTitle.setExtText(R.string.screen_main_pet_guide_preview) } else { mBinding.tvTitle.setExtText(R.string.screen_main_pet_guide_item) } } private fun animateShow() { mBinding.root.alpha = 0f mBinding.root.animate() .alpha(1f) .setDuration(300) .setInterpolator(AccelerateDecelerateInterpolator()) .start() } private fun updateItemView(sourceView: View, highlightView: View) { val location = IntArray(2) sourceView.getLocationOnScreen(location) highlightView.updateLayoutParams<ConstraintLayout.LayoutParams> { width = sourceView.width height = sourceView.height setMargins(location[0], location[1], 0, 0) } sourceList.remove(sourceView) Log.i(TAG, "updateItemView [${location[0]},${location[1]}]") } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) setContent() mBinding.tvTitle.setPXTextSize(R.dimen.common_sp_64) mBinding.tvTitle.setExtTextColor(R.color.main_highlight_guide_title) mBinding.btnOk.setPXTextSize(R.dimen.common_sp_32) mBinding.btnOk.setExtTextColor(R.color.main_highlight_guide_title) mBinding.btnOk.setExtText(R.string.screen_main_pet_guide_btn) } private fun click() { Log.i(TAG, "click size=${sourceList.size}") if (sourceList.isEmpty()) { dismiss() } else { updateItemView(sourceList[0], mainItemView) } } override fun onDestroy() { super.onDestroy() sourceList.clear() isShow = false Log.i(TAG, "onDestroy") } }
<?xml version="1.0" encoding="utf-8"?> <com.test.screensaver.view.ShapeImageView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="@dimen/common_dp_10" android:scaleType="fitXY" android:src="@drawable/icon_guide_main_item_pet" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:shapeAppearanceOverlay="@style/cornerRound24" />
<?xml version="1.0" encoding="utf-8"?> <com.test.button.ZeekrButton xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" style="@style/ButtonHMI.RealButton.Large" android:layout_width="match_parent" android:layout_height="match_parent" android:clickable="false" android:focusable="false" android:focusableInTouchMode="false" android:maxLines="1" android:text="test" android:textSize="@dimen/common_sp_32" app:cornerRadius="@dimen/common_dp_24" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/tv_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/common_dp_624" android:gravity="center_horizontal" android:text="@string/screen_main_pet_guide_item" android:textColor="@color/main_highlight_guide_title" android:textSize="@dimen/common_sp_64" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.test.button.ZeekrButton android:id="@+id/btn_ok" style="@style/Widget.ZeekrButton.GhostButton.Large" android:layout_width="@dimen/common_dp_224" android:layout_height="@dimen/common_dp_104" android:layout_marginTop="@dimen/common_dp_88" android:focusedByDefault="true" android:gravity="center" android:text="@string/screen_main_pet_guide_btn" android:textColor="@color/main_highlight_guide_title" android:textSize="@dimen/common_sp_32" app:backgroundTint="@color/screen_main_select_bg" app:cornerRadius="@dimen/common_dp_24" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/tv_title" /> </androidx.constraintlayout.widget.ConstraintLayout>
<style name="guide_dialog_theme" parent="@style/Theme26.SplashScreen"> <item name="android:windowNoTitle">true</item> <item name="android:windowIsFloating">false</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowFullscreen">true</item> <item name="android:backgroundDimEnabled">false</item> <item name="android:windowCloseOnTouchOutside">true</item> <item name="android:background">@android:color/transparent</item> <item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowContentOverlay">@null</item> <item name="android:windowBlurBehindEnabled">true</item> <item name="android:windowBlurBehindRadius">70px</item> <item name="android:windowBackgroundBlurRadius">10px</item> </style>
浙公网安备 33010602011771号