一、项目介绍
在移动应用中,为新用户提供一套引导流程可以让他们快速上手核心功能,减少“迷茫”与“流失”。其中,蒙版引导(Mask Guide) 通过对页面中重要控件进行高亮,其余区域覆盖半透明遮罩,并结合文字说明或箭头,能直观地指示用户“这里是干嘛的”、“点这里可以……”。典型场景包括:
-
首次打开 App 时引导添加好友、发布动态等关键按钮
-
功能更新后针对新交互做局部提示
-
复杂流程中分步引导,如填写信息、操作流程
本项目目标是打造一个 通用、可配置、易集成 的蒙版引导库,满足以下需求:
-
多步骤:支持按序列展示多步高亮;
-
多形状:高亮区域支持圆形、矩形、圆角矩形;
-
提示自定义:提示文字与箭头位置可自定义;
-
点击继续/退出:点击遮罩任意处可进入下一步或直接退出;
-
只显示一次:引导完成后通过
SharedPreferences
标记,不再重复展示; -
任意页面集成:可在
Activity
、Fragment
中随时调用; -
样式可配置:遮罩颜色、透明度、文字样式、箭头图标等均可 XML/Builder 方式配置。
借助本文,您将学会:
-
如何利用
Canvas
与PorterDuffXfermode
绘制遮罩与高亮; -
构建通用的引导管理器(Builder + Step 模式);
-
在不同形状目标 View 周围计算高亮区域;
-
结合布局文件和自定义 View 实现提示文字与箭头;
-
利用
SharedPreferences
实现“只引导一次”控制; -
优雅地在任意页面中启动和销毁引导组件。
二、相关知识
在动手实现之前,建议先了解以下技术点,否则后续示例难以理解:
2.1 Canvas 与 PorterDuffXfermode
-
Canvas.drawRect()/drawCircle():在画布上绘制矩形/圆形区域。
-
PorterDuffXfermode:设置绘制模式,
Mode.CLEAR
可将指定区域擦除为透明。 -
图层管理:使用
saveLayer()
/restore()
创建临时图层,保证清除模式只作用于遮罩层。
2.2 自定义 View
-
onDraw():所有绘制逻辑放这里,避免频繁新建对象。
-
onSizeChanged():获取 View 大小及绘制区域初始化。
-
属性解析:在
attrs.xml
中声明自定义属性,通过context.obtainStyledAttributes()
解析。
2.3 形状计算
-
目标 View 坐标:通过
view.getLocationOnScreen()
获取绝对位置。 -
RectF:高亮区域用
RectF
表示,可根据形状类型扩展内外边距。 -
圆角:圆角矩形由
canvas.drawRoundRect()
实现。
2.4 布局与提示
-
FrameLayout 覆盖:将引导层放在最顶层
FrameLayout
中,拦截点击事件。 -
提示布局:在高亮区域周围动态放置
TextView
、ImageView(箭头)
,可用RelativeLayout
、ConstraintLayout
进行位置控制。
2.5 步骤管理
-
Builder 模式:
GuideBuilder
用链式调用配置多个步骤、样式,最后build()
出GuideMaskView
并展示。 -
Step 列表:内部维护
List<GuideStep>
,依次在遮罩点击回调中切换。 -
生命周期:引导结束后移除遮罩层并可执行回调。
2.6 持久化控制
-
SharedPreferences:用键值对记录某次引导是否完成,下次启动时检查,决定是否展示。
-
唯一标识:每组引导配置一个
guideId
,支持不同页面的独立控制。
三、实现思路
-
GuideStep:数据模型,包含目标
View
、高亮形状、提示布局的资源 ID 以及相对于目标的 Offset。 -
GuideBuilder:链式接口,用户逐步添加
addStep(target: View, shape: Shape, tipLayoutRes: Int, xOffset: Int, yOffset: Int)
,并可设置遮罩颜色、透明度、箭头偏移、guideId。 -
GuideMaskView:继承
FrameLayout
,在其onDraw()
中绘制半透明遮罩,并对当前GuideStep
的目标区域进行clear
。同时在onLayout()
加载对应的提示布局,并根据xOffset
,yOffset
调整位置。 -
引导流程:用户调用
GuideBuilder.build()
得到GuideMaskView
并show()
,第一次加载第 0 步;用户点击遮罩,内部stepIndex++
,如果还有下一步,则invalidate()
重绘并刷新提示布局,否则执行remove()
并标记完成状态。 -
只显示一次:在构建时检查
SharedPreferences
中的guideId
标记,若已完成则直接build()
返回空实现,不显示。 -
集成方式:在
Activity.onCreate()
或Fragment.onViewCreated()
中调用一次GuideBuilder
,无需在布局中做任何改动。
四、整合代码
以下所有源文件与资源文件均整合到一个代码块中,用注释区分文件名,并附中英文双注释说明关键逻辑。
// -------------------- 文件: GuideStep.kt --------------------
package com.example.guide
import android.view.View
/**
* GuideStep:每一步引导模型
* @param targetView 需要高亮的目标 View
* @param shape 高亮形状(CIRCLE, RECT, ROUND_RECT)
* @param tipLayoutRes 提示布局资源 ID
* @param xOffset 提示布局相对于目标区域 X 方向偏移
* @param yOffset 提示布局相对于目标区域 Y 方向偏移
*/
data class GuideStep(
val targetView: View,
val shape: HighlightShape,
val tipLayoutRes: Int,
val xOffset: Int,
val yOffset: Int
)
// ---------------- 文件: HighlightShape.kt ----------------
package com.example.guide
/**
* 高亮形状枚举
*/
enum class HighlightShape {
CIRCLE, // 圆形
RECT, // 矩形
ROUND_RECT // 圆角矩形
}
// ---------------- 文件: GuidePreferenceUtil.kt ----------------
package com.example.guide
import android.content.Context
import android.preference.PreferenceManager
/**
* GuidePreferenceUtil:引导完成状态持久化工具
*/
object GuidePreferenceUtil {
private const val PREFIX = "guide_completed_"
fun isCompleted(context: Context, guideId: String): Boolean {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
return prefs.getBoolean(PREFIX + guideId, false)
}
fun markCompleted(context: Context, guideId: String) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit().putBoolean(PREFIX + guideId, true).apply()
}
}
// ---------------- 文件: GuideBuilder.kt ----------------
package com.example.guide
import android.app.Activity
/**
* GuideBuilder:链式配置引导步骤与样式,最后 build() 显示
*/
class GuideBuilder(private val activity: Activity) {
private val steps = mutableListOf<GuideStep>()
private var maskColor: Int = 0x88000000.toInt() // 半透明黑
private var guideId: String = "default_guide"
/** 设置遮罩颜色 */
fun setMaskColor(color: Int) = apply { maskColor = color }
/** 设置唯一引导 ID,用于“只显示一次” */
fun setGuideId(id: String) = apply { guideId = id }
/** 添加一步引导 */
fun addStep(
target: View,
shape: HighlightShape,
tipLayoutRes: Int,
xOffset: Int = 0,
yOffset: Int = 0
) = apply {
steps.add(GuideStep(target, shape, tipLayoutRes, xOffset, yOffset))
}
/** 构建并立即展示引导 */
fun build() {
// 若已完成,则不展示
if (GuidePreferenceUtil.isCompleted(activity, guideId)) return
val maskView = GuideMaskView(
activity,
steps.toList(),
maskColor,
onFinish = {
GuidePreferenceUtil.markCompleted(activity, guideId)
}
)
// 将遮罩层添加到 Activity 根布局
activity.window.decorView.findViewById(android.R.id.content)
.also { container ->
container as android.view.ViewGroup
container.addView(maskView,
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.MATCH_PARENT
)
}
maskView.showStep(0)
}
}
// ---------------- 文件: GuideMaskView.kt ----------------
package com.example.guide
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.*
import android.widget.FrameLayout
/**
* GuideMaskView:核心引导层,负责绘制蒙版、擦除高亮区域、加载提示布局和步骤切换
*/
class GuideMaskView @JvmOverloads constructor(
context: Context,
private val steps: List<GuideStep>,
private val maskColor: Int,
private val onFinish: () -> Unit,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var currentStep = 0
private val layerRect = RectF()
private var layer: Int = 0
init {
setWillNotDraw(false) // 启用 onDraw
paint.color = maskColor
setOnClickListener { // 点击遮罩切换步骤
currentStep++
if (currentStep >= steps.size) {
removeFromParent()
onFinish()
} else {
removeAllTipViews()
invalidate()
showStep(currentStep)
}
}
}
/** 将自身移除根布局 */
private fun removeFromParent() {
(parent as? ViewGroup)?.removeView(this)
}
/** 展示第 stepIndex 步 */
fun showStep(stepIndex: Int) {
currentStep = stepIndex
val step = steps[stepIndex]
// 添加提示布局
val tipView = LayoutInflater.from(context).inflate(step.tipLayoutRes, this, false)
// 计算目标位置
val loc = IntArray(2).also { step.targetView.getLocationOnScreen(it) }
val targetRect = RectF(
loc[0].toFloat(),
loc[1].toFloat() - getStatusBarHeight(),
loc[0] + step.targetView.width.toFloat(),
loc[1] - getStatusBarHeight() + step.targetView.height.toFloat()
)
// 显示提示布局:使用 FrameLayout.LayoutParams 设置 margin
val lp = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.leftMargin = (targetRect.left + step.xOffset).toInt()
lp.topMargin = (targetRect.bottom + step.yOffset).toInt()
addView(tipView, lp)
// 保存高亮区域
layerRect.set(targetRect)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 1. 保存图层
layer = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
// 2. 绘制全屏遮罩
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
// 3. 擦除高亮区域
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
val step = steps[currentStep]
when (step.shape) {
HighlightShape.CIRCLE -> {
val cx = layerRect.centerX()
val cy = layerRect.centerY()
val radius = maxOf(layerRect.width(), layerRect.height()) / 2f + 8f
canvas.drawCircle(cx, cy, radius, paint)
}
HighlightShape.RECT -> {
canvas.drawRect(layerRect, paint)
}
HighlightShape.ROUND_RECT -> {
canvas.drawRoundRect(layerRect, 16f, 16f, paint)
}
}
paint.xfermode = null
// 4. 恢复图层
canvas.restoreToCount(layer)
}
/** 移除所有提示布局 */
private fun removeAllTipViews() {
removeAllViews()
}
/** 获取状态栏高度 */
private fun getStatusBarHeight(): Int {
val resId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resId > 0) resources.getDimensionPixelSize(resId) else 0
}
}
// ---------------- 文件: attrs.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 在 GuideBuilder/GuideMaskView 中我们未用 attrs,此处仅示例如何定义 -->
</resources>
// ---------------- 文件: guide_tip_layout_step1.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:background="@drawable/guide_tip_bg"
android:padding="8dp"
android:layout_width="wrap_content" android:layout_height="wrap_content">
<TextView
android:id="@+id/tvTip1"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="点击这里可以发起聊天" android:textColor="#FFFFFF"/>
<ImageView
android:layout_width="24dp" android:layout_height="24dp"
android:src="@drawable/ic_arrow_down" android:contentDescription=""/>
</LinearLayout>
// ---------------- 文件: guide_tip_layout_step2.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:background="@drawable/guide_tip_bg"
android:padding="8dp"
android:layout_width="wrap_content" android:layout_height="wrap_content">
<TextView
android:id="@+id/tvTip2"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="这里可以查看设置" android:textColor="#FFFFFF"/>
<ImageView
android:layout_width="24dp" android:layout_height="24dp"
android:src="@drawable/ic_arrow_up" android:contentDescription=""/>
</LinearLayout>
// ---------------- 文件: guide_tip_bg.xml ----------------
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#CC000000"/>
<corners android:radius="8dp"/>
</shape>
// ---------------- 文件: 在 Activity 中集成 ----------------
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 获取需要高亮的 View
val chatBtn = findViewById<View>(R.id.btn_chat)
val settingIcon = findViewById<View>(R.id.icon_setting)
// 构建并展示引导
GuideBuilder(this)
.setGuideId("main_page_guide")
.setMaskColor(0xBF000000.toInt())
.addStep(chatBtn, HighlightShape.CIRCLE, R.layout.guide_tip_layout_step1, 0, 16)
.addStep(settingIcon, HighlightShape.ROUND_RECT, R.layout.guide_tip_layout_step2, 0, -64)
.build()
}
}
五、代码解读
-
GuideStep:封装单步引导所需的信息,包括目标
View
、高亮形状、提示布局资源 ID,以及提示相对偏移。 -
HighlightShape:高亮区域形状枚举,支持
CIRCLE
、RECT
、ROUND_RECT
。 -
GuidePreferenceUtil:利用
SharedPreferences
存取“guideId”完成状态,保证每个引导只显示一次。 -
GuideBuilder:链式 API,用户在
Activity
/Fragment
中配置guideId
、遮罩色和多个步骤;build()
时检查是否已完成,若未完成则创建GuideMaskView
并添加到根布局,展示第一步。 -
GuideMaskView:核心自定义
FrameLayout
,在onDraw()
:-
saveLayer 建立临时图层,
-
drawRect 绘制全屏半透明遮罩,
-
setXfermode(Mode.CLEAR) 在目标区域擦除为透明(即高亮),形状根据
GuideStep.shape
决定, -
restoreToCount 恢复画布。
同时在showStep()
中动态添加提示布局,并根据目标View
的屏幕位置与xOffset/yOffset
设置其LayoutParams
。
点击遮罩触发OnClickListener
,切换到下一步,或当步骤结束时移除自身并执行onFinish()
标记完成。
-
-
布局文件:
guide_tip_layout_step1.xml
和guide_tip_layout_step2.xml
分别为两步提示布局,包含TextView
与箭头ImageView
,背景为圆角半透明黑(guide_tip_bg.xml
)。 -
Activity 集成:在
MainActivity.onCreate()
中获取目标控件引用,使用GuideBuilder
配置并一行代码启动引导,无需修改现有布局结构。
六、项目总结
-
成果回顾
-
完全自研的通用蒙版引导库,支持多步、多形状、高度可配置;
-
使用
Canvas+PorterDuff
高效实现高亮区域擦除,无需额外View
叠加; -
Builder 模式简化调用流程,集成极其方便;
-
利用
SharedPreferences
实现“只展示一次”的业务逻辑。
-
-
技术收获
-
深入理解
PorterDuffXfermode
与 Android 图层绘制机制; -
学会动态获取目标
View
屏幕坐标并与自定义View
配合布局; -
掌握自定义
FrameLayout
结合触摸事件管理多步引导; -
熟练运用 Builder 及数据模型驱动 UI 组件。
-
-
后续优化
-
动画过渡:在切换步骤时为高亮区域及提示布局添加渐隐渐显动画;
-
手势支持:支持手指拖动、滑动切换步骤,以及“返回上一步”手势;
-
更多形状:支持椭圆、心形等自定义路径高亮;
-
性能优化:对大型页面引导时优化
onDraw()
,避免阻塞主线程; -
国际化与无障碍:提示文字支持多语言,添加
ContentDescription
,兼容 TalkBack。
-