Bluesky 截图水印技术解读:UITextField 遮蔽、GrowthHack 组件与三方实现对比

Bluesky 的 iOS 客户端有一个不易察觉的行为:正常刷帖时,帖子右上角显示的是 Follow 按钮;一旦截图,这个位置出现的却是蓝蝴蝶 logo。原文作者 Tim Marinin 在给帖子截图存档时注意到了这个细节,顺着开源代码把整套机制翻了出来:一个 66 行的组件,加上一个存在多年的 iOS 系统特性。

截图中显露出蓝蝴蝶水印(来自 Tim Marinin 原文)

截图时发生了什么

截图中的蓝蝴蝶水印与演示图(来自 PR #9637)

现象本身很简单。在 Bluesky iOS 应用里打开一条帖子,头像右侧是「关注」按钮;按下电源键和音量键截图,得到的图片里按钮位置变成蓝蝴蝶 logo。切换应用的过渡动画中途截图,看到的还是 Follow 按钮,logo 不出现。

驱动这个切换的代码在 social-app 仓库的 GrowthHack.tsx 里,文件名直译过来就是「增长黑客」。2026 年 1 月 5 日,贡献者 mozzius 提交了 PR #9637(标题 Watermark posts in screenshots),1 月 15 日合并。实现思路分两层:

  • 真正的 Follow 按钮被包进 <PrivacySensitive> 组件,在截图瞬间整体隐藏;
  • 按钮下方预先铺好一个蓝蝴蝶 logo,平时被按钮盖住,按钮消失后自然显露。

组件对布局做了两处针对性处理。Follow 按钮宽度随用户名长度变化,所以用 onLayout 回调实测按钮宽度,同步设置外层容器的 minWidth,防止 logo 显露时布局跳动;被隐藏的按钮本体设了 width: 1000 的临时宽度用于测量,配合 pointer-events: box-none 不干扰正常点击。非 iOS 平台和后台状态下组件直接返回原始按钮,不做任何处理。

UITextField 的隐私后门

隐藏能力的来源是 mozzius 自己写的 Expo 模块 expo-privacy-sensitive,iOS 侧核心类 ExpoPrivacySensitiveView 连注释带分支处理共 111 行,真正干活的部分只有 init 里的十几行。

iOS 的密码输入框 UITextFieldisSecureTextEntry = true 时,系统会把它内部的一个画布视图(类名 _UITextLayoutCanvasView)标记为敏感内容,截图和录屏时自动置空。这个模块创建一个密码输入框,取出其内部画布视图,把 React Native 传进来的子视图全部塞进这个画布里。子视图因此继承了密码内容的待遇:屏幕上正常显示,截图里一片空白。

实现里还有一段针对 React Native 新架构(Fabric)的补偿代码。新架构通过 mountChildComponentView 挂载子视图而不是 addSubview,模块重写了挂载和卸载方法,把子视图重定向到安全容器内部;卸载时不走默认的 super 调用,否则会触发「视图挂载位置不符」的断言崩溃。旧架构则逐一重写四个 addSubview 变体完成同样的重定向。

这套做法的源头是 IM 应用的防泄密需求。Telegram iOS 版的 UIKitUtils.m 中有 setLayerDisableScreenshots 函数,用同样的 TextLayoutCanvasView 查找加 KVC 换 layer 的手法给任意 layer 开关截图隐藏;Signal 的 ScreenshotBlocking.swift 注释写得更直白:利用 UITextField 内置的内容遮蔽机制,「骗」它把遮蔽标志应用到目标视图的 layer 上。Signal 的实现比 Bluesky 的更激进,直接用 setValue:forKey: 把目标视图的 layer 换进画布视图再触发一次遮蔽标志刷新,对任意视图生效。Bluesky 的贡献把这个只在安全场景使用的技术第一次带进了大众社交应用,用途从「保护用户隐私」换成了「给平台加水印」。

为什么切应用时不生效

切换应用中途截图:Follow 按钮原样保留(来自 Tim Marinin 原文)

截图隐藏的触发条件是系统在截图瞬间对实时视图层做遮蔽。切换应用时,iOS 在手势开始阶段就抓取了界面的静态快照用于动画,这个快照不经过 UITextField 的敏感内容处理;等截图发生时,活跃实例已经不在前台。所以在应用切换的过渡态截图,logo 机制完全失灵,Follow 按钮原样留在图里。Tim Marinin 用中途截图验证了这一点,这也是判断「截图加水印」和「原生渲染水印」差别的关键证据:前者依赖运行时的视图状态,后者焊死在像素里。

这套机制还有固有的失效面。HDMI 外接采集和 QuickTime 的设备录制走显示输出通道,不经过系统截图管线;网页端和 Android 端完全没有这层处理。换句话说,这个水印覆盖的只有 iOS 系统截图这一种取图方式。

社区反应与边界

PR #9637 的讨论区在 1 月 20 日后集中出现批评,随后被锁定。反对意见集中两点:mcclure 认为篡改截图内容「本质上就是恶意软件行为」;dannypage 指出这是在滥用隐私特性给截图「美容」,并提出替代方案,增加「复制帖子为图片」选项(类似 Reddit 复制图片时附加来源),把水印选择权交回用户。Meorge 补充说截图的默认预期是忠实记录屏幕当前内容,这个功能至少应该做成可选项。

从覆盖范围看,这个水印有三个明确边界:只在 iOS 客户端生效,Android 和网页端无此能力(模块在非 iOS 平台直接渲染原始内容);只出现在帖子线程的主帖上,回复不加水印;对自己的帖子不生效(没有 Follow 按钮可隐藏)。想绕过的话,除了中途切换应用截图,还可以用系统自带的整页截图、辅助功能的屏幕录制以外的取图方式,或者直接在网页端截图。

这个案例值得记住的部分

对一个以开放和用户控制为卖点的平台来说,用一个文件名叫 GrowthHack 的组件改写用户屏幕上的内容,方向耐人寻味。技术层面,UITextField 的截图遮蔽是一个存在多年的系统行为,Telegram 和 Signal 拿它保护秘密聊天,Bluesky 拿它做增长标记,同一机制在两类应用里的角色反差是这次讨论能同时登上 Hacker News 和 Lobsters 首页的原因。

对开发者,expo-privacy-sensitive 已经是可直接安装的 Expo 模块(npx expo install expo-privacy-sensitive),MIT 协议,支持 iOS 15.1+ 和 Fabric 新架构。做银行、医疗类应用时它是现成的敏感信息遮蔽方案;做社交产品时,PR #9637 锁定的评论区提供了一个 watermark 类功能的负面反馈样本。

附:关键实现代码

Telegram 的 setLayerDisableScreenshots(摘自 UIKitUtils.m):

objc
static const void *layerDisableScreenshotsKey = &layerDisableScreenshotsKey;

void setLayerDisableScreenshots(CALayer * _Nonnull layer, bool disableScreenshots) {
    static UITextField *textField = nil;
    static UIView *secureView = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        textField = [[UITextField alloc] init];
        for (UIView *subview in textField.subviews) {
            if ([NSStringFromClass([subview class]) containsString:@"TextLayoutCanvasView"]) {
                secureView = subview;
                break;
            }
        }
    });
    if (secureView == nil) {
        return;
    }
    CALayer *previousLayer = secureView.layer;
    [secureView setValue:layer forKey:@"layer"];
    if (disableScreenshots) {
        textField.secureTextEntry = false;
        textField.secureTextEntry = true;
    } else {
        textField.secureTextEntry = true;
        textField.secureTextEntry = false;
    }
    [secureView setValue:previousLayer forKey:@"layer"];
}

Signal 的 blockScreenshots(of:)(摘自 ScreenshotBlocking.swift):

swift
static func blockScreenshots(of view: UIView) {
    let textField = UITextField()
    guard
        let screenshotBlockingView = textField.subviews.first,
        String(describing: type(of: screenshotBlockingView)).contains("TextLayoutCanvasView")
    else {
        owsFailDebug("Missing expected screenshotBlockingView!")
        return
    }
    screenshotBlockingView.setValue(view.layer, forKey: "layer")
    textField.isSecureTextEntry = false
    textField.isSecureTextEntry = true
}

Bluesky 的 <PrivacySensitive> 用法(摘自 expo-privacy-sensitive README):

tsx
import { PrivacySensitive } from 'expo-privacy-sensitive';

export default function App() {
  return (
    <View>
      <Text>This is visible in screenshots</Text>
      <PrivacySensitive>
        <Text>This is hidden in screenshots!</Text>
      </PrivacySensitive>
    </View>
  );
}

来源: