背景

在现代数字交流中,屏幕截图成为一种常见的工具,不仅用于记录和保存内容,还广泛用于分享信息。一篇题为《Why do people take Screenshots on their Smartphones?》1 的研究论文指出,有97%的受访者曾将截图发送给他人。这一数据表明,屏幕截图不仅仅是个人记录的手段,更是一种重要的社交互动方式。通过截图,用户能够快速分享对话、通知或有用的内容,从而在信息传播和交流中发挥关键作用。

Image.png

然而,对于这样高比例的分享行为,用户可能的目的有:

  • 认为当前屏幕的内容有趣,想要分享给其他人;
  • 认为当前屏幕的内容不符合预期,想要反馈给开发者;

于是,我们可以从中做出这些事:

  • 在用户截图时将品牌 logo 放置在不影响其他内容的地方,提高品牌宣传力;
  • 将屏幕截图中的敏感信息隐藏起来,防止这些内容暴露给其他人,保护用户隐私;
  • 在隐私协议允许范围内将诊断信息隐藏在截图中,从而更好地优化产品可能遇到的问题;

因此,接下来我们将开始研究实现方式。

使用 mask 隐藏 / 显示信息

本质上来讲,我们如果做到了可以在截屏时隐藏信息,那也就同样做到了显示信息,我们可以通过隐藏 / 显示蒙板的内容来控制实际内容的显示。对于 SwiftUI,有以下代码:

content
    .mask {
        ZStack {
            Color.white
            HideWhenTakingScreenshot {
                Color.black
            }
        }
        .compositingGroup()
        .luminanceToAlpha()
    }

假设我们已经实现了 HideWhenTakingScreenshot 中的内容,在屏幕截图时隐藏 Color.black ,对于 content ,也就相当于在屏幕截图时显示了,UIKit 同理。

在屏幕截图时隐藏信息

经过层层过滤,其实我们的真实需求是如何在屏幕截图时隐藏信息。

UITextField 的神奇作用

说到隐私保护,我们不得不想起当用户在密码框中输入密码时作为 iOS 系统级的保护,他会在用户截屏时自动隐藏密码输入框,如以下代码所示:

struct PasswordTextView: UIViewRepresentable {
    @Binding var password: String?
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.isSecureTextEntry = true
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = password
    }
}

这是一个最简单的用 UIKit 实现的密码框,可以将这个 PasswordTextView 添加到 SwiftUI 视图中,于是我们发现,当输入一些密码后并截图,密码框会在截图中消失不见。

password

那么这是如何做到的呢?我意识到一定是 isSecureTextEntry 这个属性在作祟,因为只要去掉这一行,截图消失这一效果将不再起作用。

于是我们可以使用 LLDB 命令找到 setSecureTextEntry: 这个方法调用的具体位置。

(lldb) image lookup -n "-[UITextField setSecureTextEntry:]"
1 match found in /Library/Developer/CoreSimulator/Volumes/iOS_22A5297f/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 18.0.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore:
        Address: UIKitCore[0x00000000011406bc] (UIKitCore.__TEXT.__text + 18073272)
        Summary: UIKitCore`-[UITextField setSecureTextEntry:]

看起来这发生在 UIKitCore.framework ,将这个文件在反编译软件里进行反编译后,并找到 -[UITextField setSecureTextEntry:] ,于是得到这个方法实现的伪代码:

/* @class UITextField */
-(int)setSecureTextEntry:(int)arg2 {
    r2 = arg2;
    r1 = arg1;
    r31 = r31 - 0x30;
    var_10 = r20;
    stack[-24] = r19;
    saved_fp = r29;
    stack[-8] = r30;
    r20 = r2;
    r19 = arg0;
    [arg0 textInputTraits];
    r0 = loc_18c2b3a10();
    var_18 = r0;
    if ([r0 isSecureTextEntry] != r20) {
            r2 = r20;
            [var_18 setSecureTextEntry:r2];
            [r19 _didChangeSecureTextEntry];
    }
    r0 = var_18;
    if (((stack[-8] ^ stack[-8] * 0x2) & 0x40000000) != 0x0) {
            asm { brk        #0xc471 };
            r0 = loc_1868d24b4(r0, r1, r2);
    }
    else {
            r0 = loc_18c2b3bb0();
    }
    return r0;
}

从这段伪代码中可以看出,其中最具嫌疑的是 17 行的 [var_18 setSecureTextEntry:r2]; 和 18 行的 [r19 _didChangeSecureTextEntry]; ,我们可以先排除 18 行的影响。因我们已知 11 行 r19 = arg0 即汇编代码中 mov x19, x0 ,所以这里的 r19 相当于当前 UITextField 实例。故这一行实际是在调用 [UITextField _didChangeSecureTextEntry] 。于是我们在这行打上断点:

(lldb) b -[UITextField _didChangeSecureTextEntry]
Breakpoint 1: where = UIKitCore`-[UITextField _didChangeSecureTextEntry], address = 0x0000000185d95714

运行代码,直到程序停在断点位置,随后使用命令:

(lldb) thread return

直接返回当前函数。然后继续运行代码,我们可以看到文本框的密码特性将不再生效,截图也不再隐藏,于是我们断定具体生效位置是在 [UITextField _didChangeSecureTextEntry] 内部。

show password

证明

在源代码中我们使用 Objective-C 定义 SWTextField ,继承 UITextField ,声明 _didChangeSecureTextEntry 并覆写 isSecureTextEntry get 方法:

@interface SWTextField : UITextField

- (void)_didChangeSecureTextEntry;

@end

@implementation SWTextField

- (BOOL)isSecureTextEntry {
    return YES;
}

@end

然后将 PasswordTextView 改成:

struct PasswordTextView: UIViewRepresentable {
    @Binding var password: String?
    
    func makeUIView(context: Context) -> UITextField {
        let textField = SWTextField()
        textField._didChangeSecureTextEntry()
        return textField
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = password
    }
}

即可看到能够证明 _didChangeSecureTextEntry 方法是有效的。

Dive into [UITextField _didChangeSecureTextEntry]

回到断点 [UITextField _didChangeSecureTextEntry,我们可以在 Xcode 中看到:

UIKitCore`-[UITextField _didChangeSecureTextEntry]:
    0x185d95714 <+0>:   sub    sp, sp, #0x50
    0x185d95718 <+4>:   stp    x24, x23, [sp, #0x10]
    0x185d9571c <+8>:   stp    x22, x21, [sp, #0x20]
    0x185d95720 <+12>:  stp    x20, x19, [sp, #0x30]
    0x185d95724 <+16>:  stp    x29, x30, [sp, #0x40]
    0x185d95728 <+20>:  add    x29, sp, #0x40
    0x185d9572c <+24>:  mov    x19, x0
    0x185d95730 <+28>:  bl     0x1867a1ac0               ; objc_msgSend$_setNeedsStyleRecalc
    0x185d95734 <+32>:  mov    x0, x19
    0x185d95738 <+36>:  bl     0x1867acb20               ; objc_msgSend$_shouldObscureInput
    0x185d9573c <+40>:  mov    x20, x0
    0x185d95740 <+44>:  adrp   x22, 417875
    0x185d95744 <+48>:  add    x22, x22, #0xa48          ; _MergedGlobals + 132
    0x185d95748 <+52>:  ldrsw  x8, [x22, #0x40]
    0x185d9574c <+56>:  ldr    x0, [x19, x8]
    0x185d95750 <+60>:  mov    x2, x20
    0x185d95754 <+64>:  bl     0x18688aae0               ; objc_msgSend$setDocumentObscured:
    0x185d95758 <+68>:  mov    x0, x19
    0x185d9575c <+72>:  bl     0x18675d0a0               ; objc_msgSend$_fieldEditor
    0x185d95760 <+76>:  bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d95764 <+80>:  mov    x21, x0
    0x185d95768 <+84>:  mov    x0, x19
    0x185d9576c <+88>:  bl     0x18683da20               ; objc_msgSend$isSecureTextEntry
    0x185d95770 <+92>:  mov    x2, x0
    0x185d95774 <+96>:  mov    x0, x21
    0x185d95778 <+100>: bl     0x1868a9e20               ; objc_msgSend$setSecureTextEntry:
    0x185d9577c <+104>: bl     0x18607fb50               ; symbol stub for: objc_release_x21
    0x185d95780 <+108>: mov    w8, #0x12                 ; =18 
    0x185d95784 <+112>: cmp    w20, #0x0
    0x185d95788 <+116>: csel   w21, w8, wzr, ne
    0x185d9578c <+120>: ldrsw  x8, [x22]
    0x185d95790 <+124>: ldr    x0, [x19, x8]
->  0x185d95794 <+128>: bl     0x186844600               ; objc_msgSend$layer
    0x185d95798 <+132>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d9579c <+136>: mov    x22, x0
    0x185d957a0 <+140>: mov    x2, x21
    0x185d957a4 <+144>: bl     0x186889ba0               ; objc_msgSend$setDisableUpdateMask:
    0x185d957a8 <+148>: bl     0x18607fb5c               ; symbol stub for: objc_release_x22
    0x185d957ac <+152>: adrp   x24, 371432
    0x185d957b0 <+156>: ldr    x0, [x24, #0x6d8]
    0x185d957b4 <+160>: bl     0x1867d0b80               ; objc_msgSend$activeInstance
    0x185d957b8 <+164>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d957bc <+168>: mov    x21, x0
    0x185d957c0 <+172>: bl     0x186833120               ; objc_msgSend$inputDelegateManager
    0x185d957c4 <+176>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d957c8 <+180>: mov    x22, x0
    0x185d957cc <+184>: bl     0x186840c00               ; objc_msgSend$keyInputDelegate
    0x185d957d0 <+188>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d957d4 <+192>: mov    x23, x0
    0x185d957d8 <+196>: bl     0x18607fb68               ; symbol stub for: objc_release_x23
    0x185d957dc <+200>: bl     0x18607fb5c               ; symbol stub for: objc_release_x22
    0x185d957e0 <+204>: bl     0x18607fb50               ; symbol stub for: objc_release_x21
    0x185d957e4 <+208>: cmp    x23, x19
    0x185d957e8 <+212>: b.ne   0x185d95808               ; <+244>
    0x185d957ec <+216>: ldr    x0, [x24, #0x6d8]
    0x185d957f0 <+220>: bl     0x1867d0b80               ; objc_msgSend$activeInstance
    0x185d957f4 <+224>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d957f8 <+228>: mov    x21, x0
    0x185d957fc <+232>: mov    x2, x19
    0x185d95800 <+236>: bl     0x186888640               ; objc_msgSend$setDelegate:
    0x185d95804 <+240>: bl     0x18607fb50               ; symbol stub for: objc_release_x21
    0x185d95808 <+244>: adrp   x8, 371433
    0x185d9580c <+248>: ldr    x21, [x8, #0x398]
    0x185d95810 <+252>: mov    x0, x19
    0x185d95814 <+256>: bl     0x186878b60               ; objc_msgSend$semanticContentAttribute
    0x185d95818 <+260>: mov    x2, x0
    0x185d9581c <+264>: mov    x0, x21
    0x185d95820 <+268>: bl     0x1868e3b40               ; objc_msgSend$userInterfaceLayoutDirectionForSemanticContentAttribute:
    0x185d95824 <+272>: cmp    x0, #0x1
    0x185d95828 <+276>: b.ne   0x185d95848               ; <+308>
    0x185d9582c <+280>: mov    x0, x19
    0x185d95830 <+284>: bl     0x1868d07c0               ; objc_msgSend$textAlignment
    0x185d95834 <+288>: cmp    x0, #0x4
    0x185d95838 <+292>: b.ne   0x185d95848               ; <+308>
    0x185d9583c <+296>: mov    x0, x19
    0x185d95840 <+300>: mov    w2, #0x2                  ; =2 
    0x185d95844 <+304>: bl     0x1868b1ba0               ; objc_msgSend$setTextAlignment:
    0x185d95848 <+308>: cbz    w20, 0x185d95860          ; <+332>
    0x185d9584c <+312>: mov    x0, x19
    0x185d95850 <+316>: bl     0x1868786c0               ; objc_msgSend$selectionRange
    0x185d95854 <+320>: cbz    x1, 0x185d95860           ; <+332>
    0x185d95858 <+324>: mov    x0, x19
    0x185d9585c <+328>: bl     0x186876fc0               ; objc_msgSend$selectAll
    0x185d95860 <+332>: mov    x0, x19
    0x185d95864 <+336>: bl     0x1868359e0               ; objc_msgSend$interactionAssistant
    0x185d95868 <+340>: bl     0x18607f9d0               ; symbol stub for: objc_claimAutoreleasedReturnValue
    0x185d9586c <+344>: str    x0, [sp, #0x8]
    0x185d95870 <+348>: bl     0x1868de180               ; objc_msgSend$updateDisplayedSelection
    0x185d95874 <+352>: ldr    x0, [sp, #0x8]
    0x185d95878 <+356>: ldp    x29, x30, [sp, #0x40]
    0x185d9587c <+360>: ldp    x20, x19, [sp, #0x30]
    0x185d95880 <+364>: ldp    x22, x21, [sp, #0x20]
    0x185d95884 <+368>: ldp    x24, x23, [sp, #0x10]
    0x185d95888 <+372>: add    sp, sp, #0x50
    0x185d9588c <+376>: b      0x18607fb08               ; symbol stub for: objc_release

对于这段程序,有 3 处位置值得注意:

  • 18行的 setDocumentObsecured:
  • 27行的 setSecureTextEntry:
  • 38行的 setDisableUpdateMask:

为了筛选这些代码是哪里真正有效,我们可以单步调试到这些行,然后使用命令

(lldb) thread return

跳过其后的代码,通过当前截屏是否正常生效来确定运行过的代码是否起作用。

无论我们定位到 18 行还是 27 行,在使用 thread return 命令后都会出现在密码输入框输入的文字是 “·",而截屏后的这些字符并不会消失。直到第 38 行。

于是我们断点到第 38 行,输入命令

(lldb) po $x0
<CALayer:0x600000287100; position = CGPoint (0 0); bounds = CGRect (0 0; 0 0); delegate = <_UITextLayoutCanvasView: 0x111021eb0; frame = (0 0; 0 0); layer = <CALayer: 0x600000287100>>; opaque = YES; allowsGroupOpacity = YES; >

得知当前调用 setDisableUpdateMask:CALayer 。然后再尝试断点第 34 行,并再次输入

(lldb) po $x0
<_UITextLayoutCanvasView: 0x106021100; frame = (0 0; 0 0); layer = <CALayer: 0x6000002884c0>>

得知对应 view 是 _UITextLayoutCanvasView ,并跟据反编译结果

[*(r19 + sign_extend_64(*(int32_t *)0x1e68ec204)) layer];

然后查找地址 0x1e68ec204 后,发现是原 UITextField 的一个子 view:-[UITextField _textCanvasView]

因此我有一个大胆的猜测,只需调用任意 CALayersetDisableUpdateMask: 方法即可在截屏隐藏当前 CALayer 的内容,其对应参数是存储在 x2 寄存器存储的值。而 x2 的值是在 29 行 mov w8, #0x12 赋值的。于是猜测该方法的参数是一个枚举值。在这里该值为 0x12

因此,给 CALayer 扩展该方法:

@interface CALayer ()

- (void)setDisableUpdateMask:(unsigned int)aValue;

@end

然后我们可以实现之前未完成的 HideWhenTakingScreenshot ,并给 setDisableUpdateMask 方法传入参数0x12

struct HideWhenTakingScreenshot: UIViewRepresentable {
    var color: UIColor = .black
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.layer.setDisableUpdateMask(0x12)
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        uiView.backgroundColor = color
    }
}

最后,只需要将 content 实现为想要的效果即可,比如 Text("Watermark")

Text("Watermark")
    .mask {
        ZStack {
            Color.white
            HideWhenTakingScreenshot()
        }
        .compositingGroup()
        .luminanceToAlpha()
    }

watermark