我只是想要截个屏

目录
x
  1. 简化截屏API
    1. 截屏基础实现
    2. 截屏遇上WKWebView
    3. 页面嵌套的问题
  • 截取内容实现
    1. iOS8的诡异BUG
    2. 异步渲染粗暴解决方案
    3. 截图闪屏问题
    4. setFrame破坏
    5. 截取内容又遇见WKWebView
  • 关于性能
    1. 绘图性能问题
    2. 内存过大问题
  • 总结
    1. 参考链接
  • 想必使用iPhone的用户, 大家都知道按照Home键+电源键就可以截屏了。 截屏对于产品经理、工程师、设计师都比较重要。那么在iOS中用代码截屏也是再常用不过的功能了~ 那么在iOS研发中, 怎么样才能有效的截屏呢? 笔者在上周用了2天时间去写了一个Swift版本的截图开源库 - SwViewCapture

    起初笔者有一个小小的想法, 怎么样去截取整个网页甚至整个滚动视图的内容呢? 造一个支持该功能的开源库会不会受欢迎呢? 基于CocoaChina+ App的分享思路以及笔者自己的一点小想法, 笔者决定写一个方便Swift开发者使用的截屏库, 支持截取页面载体所有内容的库。

    • 该想法的起源来自于@子循的一个开源App - CocoaChina+, 在该App中, 用户可以分享用户正在浏览的页面内容, 也就是WebView的内容。

    大家可能好奇, 就这么一个截屏, 需要写2天么? 一开始笔者的想法很简单: 无非就写一个截屏库。笔者真正实际写起来的时候, 才发现原来光光一个截屏也有这么多的坎等着我去踩。笔者代码截屏中遇到的困难在此处梳理了一下, 防止大家也重复采坑。

    简化截屏API

    在刚开始写SwViewCapture的时候, 笔者想实现的简单点, 先实现基础的截屏功能, 可以将任意的View直接转化成一张UIImageView。

    截屏基础实现

    这个功能估计大部分iOS研发者都涉及到过, 在iOS7以前就一直用CoreGraphic的方式去Draw出对应的图。关键代码如下:

    1
    2
    3
    4
    UIGraphicsBeginImageContextWithOptions(bounds.size, false, UIScreen.mainScreen().scale)
    self.layer.renderInContext(context!)
    let capturedImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()

    上图代码的关键代码renderInContextCALayer的方法, CALayer是CoreGraphic底层的图层, 组成UIView。UIGraphic等相关操作Context是Quartz 2D框架中的API, 而Quartz 2D是CoreGraphic的其中一个组成。

    仅仅4行代码基本已经能够满足大部分的需求了~ 大部分是因为笔者在目前除了在WKWebView上此截图方法截图失败, 暂时还有在其他的View上截图失败, 有待继续检查。那么WKWebView又有什么问题呢?

    截屏遇上WKWebView

    笔者在写SwViewCapture的时候, 尝试去截取WKWebView的图。截图的结果返回给我的就仅仅只是一张背景图, 显然截图失败。通过搜索StackOverflow和Google, 我发现WKWebView并不能简单的使用layer.renderInContext的方法去绘制图形。

    如果直接调用layer.renderInContext需要获取对应的Context, 但是在WKWebView中执行UIGraphicsGetCurrentContext()的返回结果是nil (具体的原理暂时还不明, 待笔者知晓之后会补充)

    StackOverflow提供了一种解决思路是使用UIViewdrawViewHierarchyInRect方法去截取屏幕视图。

    通过直接调用WKWebView的drawViewHierarchyInRect方法(afterScreenUpdates参数必须为true), 可以成功的截取WKWebView的屏幕内容。

    页面嵌套的问题

    在查找资料设法解决WKWebView截屏问题的时候, 无意中搜索到Chrome开源项目chromium的截屏源码SnapshotManager。笔者在阅读源码的时候发现自己漏考虑了一个大场景:

    • 基础的UIView包含WKWebView场景下的截屏

    参考SnapshotManager中的解决方案, 定义一个递归函数去判断是否包含了WKWebView:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public func swContainsWKWebView() -> Bool {
    if self.isKindOfClass(WKWebView) {
    return true
    }
    for subView in self.subviews {
    if (subView.swContainsWKWebView()) {
    return true
    }
    }
    return false
    }

    最终普通截屏的方案为:

    • view中任意一个子View包含WKWebView, 则采用drawViewHierarchyInRect的方式去截取视图
    • view中任意一个子View都不包含WKWebView, 则采用renderInContext的方式去截图

    大家可能好奇为啥不全部采用drawViewHierarchyInRect的方式好了, 还多此一举来个判断, 引用chromium源码SnapshotManager中的注释来解释为什么

    -drawViewHierarchyInRect:afterScreenUpdates:YES is buggy as of iOS 8.3.

    Using it afterScreenUpdates:YES creates unexpected GPU glitches, screen

    redraws during animations, broken pinch to dismiss on tablet, etc. For now

    only using this with WKWebView, which depends on -drawViewHierarchyInRect.

    TODO(justincohen): Remove this (and always use drawViewHierarchyInRect)

    once the iOS 8 bugs have been fixed.

    PS: 写iOS的这些年来, 多多少少已经碰到不少的iOS系统原生BUG, 也是醉了

    截止到笔者写本篇博客的时候, chromium项目master上仍旧还存在该段注释。

    笔者将上述基础截屏功能封装了一下, 在SwViewCapture库中, 仅仅需要一行代码即可实现截图功能:

    1
    2
    3
    view.swCapture { (capturedImage) -> Void in
    // Do something with capturedImage(UIImage)
    }

    截取内容实现

    普通截屏实现了, 那么就开始想怎么去实现全内容的截屏。开发一个复杂的功能, 第一步就是先把功能简单化实现, 那么笔者一开始就拿UIWebView作为实验对象去实现内容的截取功能。那么问题来了, 怎么实现呢?

    通过打印UIWebView内部的UIScrollView的尺寸, 可以初步了解到UIWebView的内容本质上其实是承载在内部的UIScrollView中的。那么一个简单的耗内存的实现思路就冒出来了:

    将UIWebView的长宽修改为UIScrollView的内容尺寸大小, 然后将UIWebView用普通截图的方式截取出来。

    基于上述这个简单的想法, 笔者立马想到是否可以直接对UIWebView内部的UIScrollView进行长宽修改操作并截屏, 如果可行的话, 则可以直接引申使用在UITableView以及基础的UIScrollView上了。

    基本实现代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    UIGraphicsBeginImageContext(scrollView.contentSize)
    let savedContentOffset = scrollView.contentOffset
    let savedFrame = scrollView.frame

    scrollView.contentOffset = CGPointZero
    scrollView.frame = CGRectMake(0, 0, scrollView.contentSize.width, scrollView.contentSize.height)

    scrollView.layer.renderInContext(UIGraphicsGetCurrentContext()!)

    let image = UIGraphicsGetImageFromCurrentImageContext();

    scrollView.contentOffset = savedContentOffset;
    scrollView.frame = savedFrame;

    UIGraphicsEndImageContext()

    这个场景下, UIScrollView可以被正常的截图, 那么引申修改应用在UIWebView上应该不是什么难事吧?

    PS: UIWebView有个get方法可以获取对应的UIScrollView

    iOS8的诡异BUG

    在基本的场景下, 该方法都可以正常的截取UIScrollView, 但是在iOS8环境下会出现尾部可视区域为黑色的异常BUG。(这个BUG可能和UIScrollView在被UINavigationController托管下的VC下产生的)

    通过一阵搜索Stackoverflow和CocoaChina+的源码提示, 有一个比较合适的解决方法:

    add scrollview to another temp view and render it.

    把UIScrollView单独拎出来, 放在其他临时的UIView里单独渲染。通过该方法果然可以将iOS8的渲染问题给屏蔽掉。

    改进方案合成示意

    异步渲染粗暴解决方案

    将之前所描述的截图应用到实际场景中, 笔者发现有些网页的元素是异步加载的, 即只有页面滚动到对应的部分, 才会执行渲染加载(笔者的博客首页主题就是这种场景)。另外, UIScrollView和UITableView中也不缺乏这种场景。

    对于这种异步的方式, 没有一种完美的解决方案, 笔者只能解决一种暴力的方式解决部分案例:

    • 截屏前滚动ScrollView至底部, 再滚动回首部

    截图闪屏问题

    通过Stackovetflow - Getting a Screenshot of a UIScrollView including offscreen parts中的方式修复了iOS8的截图多个黑色区域的诡异BUG后, 在实际截图中发现了另外一个问题:

    • 将屏幕中正在显示的View拎出来放置在其他View中渲染, 渲染完毕后再恢复, 可能会出现一闪而过的情况。

    上述现象产生的原因大家估计都知道: 因为View离开当前视图的时候, 触发了界面渲染, 显示界面中的视图已经不在显示界面中, 自然就变成了背景色。

    既然要做一个截屏库, 那么这个问题也是需要解决的, 总不能让人家调用截屏API的时候闪一下吧?

    笔者思考了一下, 决定引入一张当前view的截图并遮盖在此view的父view上, 让大家视觉产生一种幻觉, 来掩盖真正的视图的操作。

    基于iOS7中View提供的API - snapshotViewAfterScreenUpdates, 可以直接生产一个截屏视图View, 剩下的工作如下:

    1. 通过snapshotViewAfterScreenUpdates产生假的遮盖View
    2. 选择目标视图的parentView, 和目标视图在parentView的层级
    3. 将遮盖view添加到目标视图的parentView中的相同层级中
    4. 执行真正的截图逻辑
    5. 将假的遮盖View从视图中移除

    伪图遮盖示意

    通过上述方法, 截图闪屏的问题就完美解决了~

    setFrame破坏

    大家注意到在执行全内容截屏的时候, 会动态的去修改UIScrollView的frame, 然后执行相应的逻辑内容。在执行截图逻辑功能的时候, 往往会涉及异步的操作, 那么在下述场景下截图可能会出现异常:

    1. 用户在对应的layoutSubView中设置修改了需要截图的view的frame
    2. 用户在截图过程中对需要截图的view的frame进行了操作

    其实场景2包含了场景1, 总结下就是在截图过程中, 任意的frame操作都会对截图行为造成破坏, 但是frame操作可能是由layoutSubView等系统函数触发的

    既然出现这个问题, 那么笔者就要解决这个问题, 笔者能够想到的就是在截图过程中无效化对该View任意的frame操作

    既然已经想到了解决方案, 那么就设计代码实现。笔者一开始尝试的方法是通过运行时对UIView绑定一个isCapturing的属性, 然后override目标视图的framesetget方法, 在set方法中通过判断是否截图中去实现是否调用super的frame操作。但是在实际操作中发现, 目标视图的framesetget的操作并单纯的做了读取和写入的操作, 还有系统的对该视图的操作逻辑存在, 因此不能通过该方式去禁用frame

    在override frame的方案失败后, 笔者又尝试了下述方案:

    • 利用Runtime在截图前替换目标view的setFrame方法, 然后在截图结束后, 用运行时将其复原

    技术实现如下:

    1
    2
    3
    4
    5
    6
    7
    let method: Method = class_getInstanceMethod(object_getClass(self), Selector("setFrame:"))
    let swizzledMethod: Method = class_getInstanceMethod(object_getClass(self), Selector("swSetFrame:"))
    method_exchangeImplementations(method, swizzledMethod)
    // capturing
    // ...
    // capturing
    method_exchangeImplementations(swizzledMethod, method)

    PS: swSetFrame方法是一个空方法, 用来无效化设置frame

    这里抛给大家一个问题: 约束是否也有相同的问题呢?

    关于SwViewCapture库对约束的处理是笔者将来的优化点之一

    截取内容又遇见WKWebView

    基础的截图功能和BUG都已经解决的差不多了, 那么来试试最麻烦的WKWebView吧。经过简单测试, 通过前面封装UIScrollView的截图方式果然不能对WKWebView进行全内容截图

    笔者好奇WKWebView的组成结构, 就通过扫描subview的方式打印了WKWebView:

    打印方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    for subView in (webView?.subviews)! {
    print("v name: \(subView.dynamicType)")
    if String(subView.dynamicType) == "WKScrollView" {
    // Do something
    }
    for subSubView in subView.subviews {
    print("sub name: \(subSubView.dynamicType)")
    if String(subSubView.dynamicType) == "WKContentView" {
    // Do something
    }
    }
    }

    打印结果:

    • WKWebView
      • WKScrollView
        • WKContentView

    WKScrollView的基类是UIWebScrollView, UIWebScrollView的基类是UIScrollView, 并且WKContentView下包含了和实际网页内容一样的宽和高。那么是否可以通过获取WKWebView下面的WKScrollView进行截图呢?

    笔者开心的以为找到了突破口, 赶紧尝试截图。结果无论是对WKWebView本身截图或者对其任意一个子类截图, 结果截图的结果仍旧还是空白一片。

    在上述两个方式都失败的无奈情况下, 笔者暂时没有特别好的解决方案, 绝对先临时采用暴力的渲染方式去合成一张大截图:

    • 截取屏幕显示范围大小的视图, 滚动, 按页截图, 滚动, 按页截图, 循环操作直到滚动到最后一页, 最后将所有截取的图片合成为一张大图。

    截图合成示意

    通过上述截图方案截取的图片仍存在不完美的部分, 尤其针对标签为position: fixed;div元素。标签为position: fixed;div元素会循环出现在生产的大图上。

    针对这种场景, 笔者暂时不计划处理, 因为WKWebView的这种截图方式暂时还不是笔者理想的截图方式, 需要急用的童鞋们可以暂时使用业务逻辑方式去处理:

    1. 扫描position: fixed;的元素
    2. 计算postion: fixed; 的元素距离顶部和尾部的高度
    3. 根据顶部和尾部的距离分别进行对应的显示和隐藏操作
    关于WKWebView截图, 如果大伙计大家谁有更好一些的截图方案, 请告知, 相互学习提高; 本人也会不断尝试新的方式, 看看能否和UIWebView一样完美的截取WKWebView; 笔者认为WKWebView绝对是有办法完美截取所有内容的, 只是暂时没有找到

    基于前面内容截图的实现描述, 笔者将API简化封装, 只需要使用SwViewCapture的时候, 调用如下代码即可使用:

    1
    2
    3
    view.swContentCapture { (capturedImage) -> Void in
    // Do something with capturedImage(UIImage)
    }

    关于性能

    绘图性能问题

    编写客户端程序的大家可能都知道, 只有主线程可以操作视图。因为iOS和Android系统在底下做了保护, 非主线程操作视图的行为都可能产生不可预计的后果, 因此系统发现在非主线程进行视图操作的时候, 往往会主动抛出异常。CoreGraphic(draw)的方式绘制视图是可以支持多线程操作, 绘制过程也不会被视图展现出来。

    在写SwViewCapture的时候, 我曾尝试着用异步(其他线程)的方式去操作绘图过程, 在renderInContextdrawViewHierarchyInRect下均作过尝试。

    • renderInContext对异步绘制支持的挺好, 并没有出现截图失败以及系统闪退的情况。
    • drawViewHierarchyInRect对多线程并不能友好支持, 可能因为drawViewHierarchyInRect是UIView的方法的缘故, 在GCD异步线程中用drawViewHierarchyInRect绘制的图像会出现绘制丢失和失败的情况。

    鉴于drawViewHierarchyInRect方法对线程支持的友好性不够, 笔者在第一版本的SwViewCapture中并没有考虑去使用多线程的方式去优化性能, 是需要进一步改进和尝试的地方。

    内存过大问题

    笔者在第一版本的SwViewCapture中没有去考虑内存问题, 因此如果非常长的UIScrollView去截取图片的时候可能会出现卡顿甚至闪退的现象。

    笔者认为在截图之前, 用户自己应该大概知道自己要截图的视图的大小, 需要进行一定的预先处理。笔者计划在以后引入区域块截图的方式让用户自定义的去控制截取视图的大小, 这样来规避可能存在的截图导致内存过大的问题。

    总结

    笔者一开始只是想实现一个截取网页内容的一个小功能, 在实现过程中发现截图竟然也可以踩出这么的坑, 便将采坑的过程总结成这篇文章, 供大家参考。同时, 将采坑实现的产物以Swift库SwViewCapture的形式开源在Github上。

    笔者将题目取为我只是想截个屏, 是一种自嘲的方式来描述程序员实现一个功能的艰辛啊~

    PS: 本人水平有限, 有错误之处请大家及时指出~~ 如果觉得文章有用, 可以多多来访哇~

    参考链接

    1. StackOverflow - WKWebView Screenshots
    2. StackOvetflow - Getting a Screenshot of a UIScrollView including offscreen parts
    3. SwViewCapture
    4. CocoaChina+
    5. chromium - SnapshotManager
    6. Object-C Runtime Reference
    如果您觉得文章有用, 打赏一个呗~~ ( ⊙ o ⊙ )/
    本站总访问量 本站访客数人次