截屏检测与支持浮层:在用户最想求助的时刻提供入口

用户在应用里遇到问题时,最自然的动作之一是截屏。截屏可能意味着用户想保存信息,也可能意味着页面出现异常、展示不清楚、内容有疑问或流程卡住。传统反馈入口通常藏在设置页、业务记录页或支持中心,用户真正遇到问题时,未必知道入口在哪里,也未必愿意描述复杂上下文。

截屏反馈的价值在于降低表达成本:用户已经用图片保存了问题现场,客户端可以补充页面标识、设备信息、网络状态和最近操作摘要,再引导用户进入支持或反馈流程。但这个过程需要非常克制——截屏不一定是要反馈,如果每次截屏都弹窗,会造成打扰。

我们项目里已经具备这条链路的基础:common 中有截图检测相关能力,biz_component 中有 support 目录,包括支持入口、截图支持入口、支持弹层管理等组件;BaseBizActivity 作为业务 Activity 基类,又能统一接入页面状态、自动刷新和生命周期。截屏反馈不是某个页面临时监听 MediaStore,而是通过基类/组件把”截图信号 → 当前页面上下文 → 支持浮层”串起来。

检测:从媒体变化到截屏判断

Android 上检测截屏通常通过监听媒体库变化实现。系统截屏后会写入图片媒体记录,应用可以通过 ContentObserver 观察媒体 URI 的变化,再根据文件名、时间、尺寸、路径特征等判断是否为截屏。不同厂商、不同语言环境下,截屏文件命名差异很大,策略上应该允许检测失败,而不是为了覆盖所有情况引入高误判:

class ScreenshotDetector(private val clock: Clock) {
    fun isScreenshot(change: MediaChange): Boolean {
        val recent = clock.nowMillis() - change.createdAtMillis < 3_000
        val nameLooksLikeScreenshot =
            change.displayName?.contains("screenshot", ignoreCase = true) == true ||
            change.displayName?.contains("screen", ignoreCase = true) == true
        val pathLooksLikeScreenshot =
            change.relativePath?.contains("screenshot", ignoreCase = true) == true
        val sizeLooksLikeScreen = ScreenSizeMatcher.matches(
            width = change.width, height = change.height
        )
        return recent && sizeLooksLikeScreen &&
            (nameLooksLikeScreenshot || pathLooksLikeScreenshot)
    }
}

策略层:比检测层更重要

应用级监听只产生事件,不直接持有 Activity。策略层负责根据当前页面、冷却时间、隐私规则和远程开关决定是否触发:

class FeedbackPolicy(
    private val cooldown: CooldownStore,
    private val remoteSwitch: RemoteSwitch
) {
    fun shouldShow(context: FeedbackContext): Boolean {
        if (!remoteSwitch.screenshotFeedbackEnabled) return false
        if (!context.appInForeground) return false
        if (!context.pageAllowsFeedback) return false
        if (context.pageContainsSensitiveInput) return false
        if (cooldown.inCooldown("screenshot_feedback")) return false
        return true
    }
}

敏感页面默认禁用:登录、关键流程、身份认证、聊天私密内容、地址表单、银行卡相关页面,都不适合自动弹出反馈浮层。即使展示入口,也不应自动附带截图。

协调器:连接检测与展示

协调器负责把系统事件 → 策略判断 → 浮层展示串起来,同时保证生命周期安全:

class ScreenshotFeedbackCoordinator(
    private val detector: ScreenshotDetector,
    private val policy: FeedbackPolicy,
    private val visiblePageProvider: VisiblePageProvider,
    private val overlayController: FeedbackOverlayController,
    private val reporter: FeedbackReporter
) {
    fun onMediaChanged(change: MediaChange) {
        if (!detector.isScreenshot(change)) return

        val page = visiblePageProvider.currentPage() ?: return
        val context = page.feedbackContext()

        if (!policy.shouldShow(context)) {
            reporter.reportSuppressed(context.pageName)
            return
        }

        overlayController.show(
            page = page,
            model = FeedbackOverlayModel(
                title = "需要帮助吗",
                actionText = "联系支持",
                context = context.toSafePayload()
            )
        )
        reporter.reportShown(context.pageName)
    }
}

浮层展示要克制——一个轻量底部条或小型悬浮入口,文案简短,提供关闭按钮。不要强制跳转,不要遮挡关键按钮,不要让用户以为截屏被自动上传。

上下文脱敏与反馈链路

页面上下文要主动脱敏,不采集输入框内容、鉴权信息、完整链接、精确位置等敏感数据:

data class FeedbackContext(
    val pageName: String,
    val pageAllowsFeedback: Boolean,
    val pageContainsSensitiveInput: Boolean,
    val appInForeground: Boolean,
    val lastSafeErrorCode: String?,
    val safeOperationName: String?
) {
    fun toSafePayload(): FeedbackPayload {
        return FeedbackPayload(
            pageName = pageName,
            errorCode = lastSafeErrorCode,
            operation = safeOperationName
        )
    }
}

进入支持链路时,截图需要用户确认后再附加:

class FeedbackNavigator(
    private val router: Router,
    private val consentDialog: ConsentDialog
) {
    fun openCustomerService(payload: FeedbackPayload, screenshot: ScreenshotRef?) {
        consentDialog.ask(
            message = "是否附上刚刚的截图以便定位问题",
            onConfirm = {
                router.openFeedbackCenter(
                    payload = payload,
                    attachment = screenshot?.safeToken()
                )
            },
            onCancel = {
                router.openFeedbackCenter(payload = payload, attachment = null)
            }
        )
    }
}

这里的 safeToken 代表客户端内部生成的临时附件引用,不应是可公开访问的业务 URL,也不应包含鉴权参数。

落地中的关键约束

设置冷却时间和会话限制。比如同一会话只展示一次,同一页面短时间内不重复展示,用户手动关闭后当天不再提示。截屏是用户主动动作,但浮层仍然属于打扰。

反馈链路要能独立降级。支持系统不可用时,浮层应隐藏或进入本地反馈页;上传附件失败时,仍然允许用户提交文字问题;网络异常时,给出可重试状态。

数据上报要关注完整漏斗。检测次数、策略拦截次数、浮层展示次数、点击次数、用户确认附图次数、反馈提交成功次数、支持解决率都值得观察。只看展示量没有意义,关键是它是否减少用户流失和重复沟通成本。

支持入口要携带可解释的上下文。支持同学看到的不是一串技术字段,而应该是页面名称、问题发生时间、用户选择的反馈类型、是否附图、最近一次安全错误码等可读信息。技术字段可以用于排查,但不要把内部枚举直接暴露给用户或一线支持。


截屏反馈的核心价值是抓住用户最接近表达问题的时刻,但它必须建立在尊重用户和保护隐私的基础上。检测只是第一步,真正决定体验的是策略过滤、浮层克制、上下文脱敏和支持链路闭环。一个好的方案应该让用户觉得”这里刚好有一个帮助入口”,而不是”应用在监视我的相册”。最终目标不是提高弹窗次数,而是让用户遇到问题时更容易获得帮助,让团队更快拿到可定位、可复现、可跟进的反馈。

Native/H5 路由灰度切换:用 RedirectRouterInterceptor 实现零风险页面迁移

同一入口存在 Native 和 H5 两种实现时,如何在路由层安全地灰度切换?本文介绍 RedirectRouterInterceptor 的通用设计,通过远程配置控制落点,配合稳定散列、参数映射、兜底策略和结构化监控,让 Native 新页面平滑上线,异常时快速回滚。

SmartDependency 源码/AAR 双模式依赖体系:让模块化工程既快又稳

在大型 Android 工程中,模块数量增长后依赖方式直接影响研发效率。本文介绍一种源码/AAR 双模式依赖体系,通过统一注册、配置切换、版本治理和 CI 约束,让开发者按需打开源码模块,同时保证发布时回归二进制真实形态。

动态 Launcher Icon 与启动入口切换:换图标背后的工程治理

动态 Launcher icon 看似只调用一次 PackageManager,实际却涉及 Manifest 声明、状态机、回退策略、桌面兼容和灰度控制。本文介绍一种通用的动态启动入口切换方案,讲清楚为什么"换图标"的能力需要完整的入口状态管理设计。

启动框架分阶段初始化:background/activity 两类 StartType 的设计与实践

App 启动阶段承载大量初始化逻辑,如果全部堆在 Application 中,冷启动耗时不可控。本文介绍一种分阶段初始化框架,将任务按 background 和 activity 两类 StartType 拆分,配合依赖声明、线程调度、异常降级和耗时监控,让初始化在正确时间完成必要工作。