原生页面预加载

概述

在客户端开发中,列表类型页面大多都依赖网络恳求,需要等网络数据恳求下来后再改写页面。但遇到网络恳求慢的场景,就会导致页面加载很慢乃至加载失利。

我担任会员的产品列表页面,在事务场景中,页面元素比较杂乱,而且涉及多个接口。最开始涉及十个左右的接口,经过我推进聚合后还有三个接口。所以,在进入产品列表页面时,需要等三个接口都恳求完结才能改写页面,这样会导致进入页面速度很慢。

整体思路

关于页面加载慢乃至失利的状况,能够对页面进行预加载,预加载也细化为prefetchpreload两部分,这两部分在计划中都包含。

计划先经过缓存数据将页面进行烘托,进入产品列表页后,再依据恳求下来的服务器数据,决定是否用服务器数据对页面进行改写。而且,用服务器的数据替换本地数据,供下次运用。看似比较简略,实际上做起来有很多细节需要处理和优化。后边的文章,将产品列表页统称为产品页。

prefetch

接口缓存

现在进入产品页有三个接口,接口数据恳求下来后,会经过SVRequest网络库自带的缓存功用,对页面数据进行缓存。下次进入页面时,会先读取本地缓存数据,假如本地有缓存数据,会先用缓存数据改写页面。改写后,等网络数据恳求下来,会判别网络数据和本地数据的一致性,依据成果决定是否用网络数据进行reload

[self startRequestWithType:SVRequestTypeGet requestURL:requestURL cacheable:YES params:params cacheBlock:^(SVNetworkCache * _Nullable cache, SVRequestControl * _Nonnull requestControl) {
    if (cache.cacheObj && [Reachability currentNetworkType] == NotReachable) {
        [requestControl stop];
    } else {
        [requestControl goOn];
    }
    [self parseData:data successBlock:cacheBlock];
} successBlock:^(id  _Nonnull responseObject) {
    if (successBlock) {
        NSDictionary *data = [responseObject as:[NSDictionary class]];
        [self parseData:data successBlock:successBlock];
    }
} failureBlock:^(SVRequestError * _Nonnull error, id  _Nonnull responseObject) {
    if (failedBlock) {
        failedBlock(error, responseObject);
    }
}];

新用户

可是,关于新安装的用户,或许旧版别升级上来的用户,他们并没有本地缓存数据,之前版别也没有敞开缓存功用。这样第一次进入产品页,仍然要等候网络数据恳求下来后,再改写页面。

为了提升这一部分用户的体会,在产品页的主要进口的方位,在进口页面显现后,会在后台现成check缓存数据状态。假如没有缓存数据,则会预先恳求服务器数据,并写入本地缓存,这样能够确保进入产品页,页面不会为空。由于仅针对新用户和旧版别升级上来的用户,所以恳求数量添加有限,不会导致过多的后台压力。

而且,由于是多个接口,所以做的是每个接口的按需加载,只有没缓存数据的接口才会恳求。一般,一个接口没数据其他两个也都没数据。可是,这个战略是防止阅读进口页面时,其中有一个接口恳求失利,但没进入产品页,下次再阅读到进口页面还会继续恳求,提高了缓存命中率。

preload

布局

为了确保push进入页面时,用户看到的便是已经烘托好的页面,所以需要对页面进行preload并烘托,时机选在初始化页面时进行。在初始化页面后,会依据本地读取的cacheData对页面进行layout,而且会调用layoutIfNeeded强制触发图形树中每个节点的布局。

这个进程相对比较顺滑,依据页面FPS的监测,并没有出现明显的FPS下降。而且,为了防止preload的进程影响埋点的准确性,将埋点和preload的进程进行剥离,当页面真正显现的时候才会进行上报。

从功能的角度,假如想在preload进程中坚持比较高的FPS,应该防止产生离屏烘托和杂乱的布局,这两项都是比较耗费CPU的,CPU耗费的添加就会影响主线程的运行,然后导致卡顿。而体系烘托操作是经过GPU进行的,不会过多耗费CPU功能,而且烘托操作相关于animation和交互式的gesture所带来的功能耗费,会少很多。

烘托原理

上面讲到了preload的话题,这儿正好简略剖析下页面烘托的原理。

先简略说明一些常见的关键词,UIView担任布局和事情响应,CALayer担任页面的烘托。先对视图进行制作,例如三角型、纹路的核算,最后再烘托成bitmap交给帧缓冲区,制作和烘托是一个先后次序。

iOS体系上采用双缓冲区机制,frame buffer前帧缓冲区,以及back buffer后帧缓冲区。CALayer不会直接跟frame buffer打交道,一般都是提交给back buffer

烘托的进程,总的来说分为三步。

  1. 当收到VSync信号后,App会经过CPU在主线程,核算显现内容,例如视图的创立和布局。
  2. 随后将核算成果提交到GPU,进行改换、组成、烘托等操作,GPU会将烘托后的成果交给back buffer
  3. 视频控制器收到下一个VSync信号后,会将上次烘托的back buffer中的bitmap显现到屏幕上。但假如CPUGPU没有核算完结,这一阵就会被丢弃,然后导致掉帧。

上述烘托逻辑对应到iOS体系上便是如下逻辑。

  1. VSync信号到来时,视频控制器会从CALayercontents中取走bitmap,并显现在屏幕上。
  2. contentsbitmap核算逻辑如下。
  3. UIView担任布局,当页面布局产生改动后,由UIViewlayoutSubviews来完结核算,这个进程是经过CPU进行的。
  4. 布局完结后,UIView会调用setNeedsDisplay,而且调用CALayer的同名办法setNeedsDisplay,这个进程相当于做一个标记,下次runloop循环会进行烘托。
  5. CALayerdisplay办法会判别是否完结了displayLayer:办法,在办法中咱们能够完结异步制作办法,没有完结则进入体系默认制作办法。
  6. CALayer会经过CGContextRef创立一个backing store,后续的制作都在这个context上进行,包含自界说的drawRect
  7. 调用drawInContext:办法进行体系制作,由Core GraphicsAPIcontext上完结制作操作。
  8. 将制作的成果烘托后的bitmap存储在contents特点中,bitmap也便是一张位图

reload

改写逻辑

为了确保用户体会,用缓存数据展现页面后,当网络数据恳求下来,会对网络数据进行比对,假如网络数据不同则用网络数据改写页面,以确保页面的准确性。假如网络数据和本地数据相同,则没必要进行一次无谓的改写,会带来额定的功能耗费,以及欠好的用户体会。

可是,产品页和其他事务还不太一样,并不是单一数据接口,所以规划一套灵活且适用于多个接口,进行hash比对的manager就比较重要。为了处理这个问题,规划了一套简略的多接口hash比对的计划。

多接口hash

计划用SVPCacheManager类来完结,能够对多个接口的hash进行管理。主要有几个职责,搜集缓存hash、搜集网络数据hash、多个hash的比对。整体是经过两个数组完结的,cacheHash担任搜集缓存数据hash的,netHash担任搜集接口数据hash。由于涉及多个接口,所以采用数组的规划,每个接口对应一个index,相同接口的缓存和网络数据核算的hash,搜集时对应同一个index,即可确保次序的问题。

为了确保通用性,也能够应用在其他接口的处理上,所以在初始化数组时是经过config配置count的。

@objcMembers class SVPCacheManager: NSObject {
    var cacheHash: [String]?
    var netHash: [String]?
    @objc static let shared = SVPCacheManager()
    func config(count: Int) {
        cacheHash = [String](repeating: "", count: count)
        netHash = [String](repeating: "", count: count)
    }
    func appendCache(index: Int, hash: String) {
        if hash.length > 0 {
            cacheHash?[index] = hash
        }
    }
    func appendNet(index: Int, hash: String) {
        if hash.length > 0 {
            netHash?[index] = hash
        }
    }
    func isEqual() -> Bool {
        return cacheHash == netHash
    }
}

易变参数

产品页接口有很多简单产生改动的字段,例如活动模版会有和时刻相关的expire time时刻戳,或许H5页面用的html标签字符串,以及一些用不到的play count format。这些字段都很简单产生变化,而且会导致cacheManagerhash值匹配失利。

为了添加匹配度,关于缓存数据和网络数据中,这些没用的易变参数,经过KVC的办法去掉。对处理后的Dictionary核算hash,这样能够使网络数据和缓存的匹配率大大提升,提升用户体会。

有序字典

字典是一个无序的数据结构,在生成hash时,是经过接口数据去除易变参数后,对Dictionary字符串生成的md5作为hash。但由于体系对json转化Dictionary的进程并不安稳,导致每次key的先后次序都是不同的,而且这个进程没有规则。

下面便是一个相同接口,两次不同恳求转为Dictionary后,keyvalue的对比。这样的次序,相同的数据根本每次比对都会导致匹配失利,最后核算的hash值也是不同的。

原生页面预加载

这时候重要的便是把无序的字典变为“有序”,做法是自界说字典的遍历办法,界说一个可变字符串,从根节点出发,一层一层进行递归遍历。

先对根节点的key数组进行排序,并将排序成果转为字符串后,append给可变字符串。再经过有序key数组取出对应的value,先将非字典和数组value,逐个append到可变字符串上,随后再递归调用该办法,并将value为字典和数组的值传入。假如传入的是数组对象,则先遍历非字典和数组的value,逐个append到可变字符串上,再进行递归调用并传入参数。

一向递归重复上面的动作,直到叶子节点停止,整体思路便是经过可变字符串,一层层拼接排序后的keyvalue,最后用拼接后的可变字符串核算md5作为hash。为了确保成果的准确性,不能只对value进行遍历,因为要考虑相同value但取值逻辑不同的状况。

analyze

为了方便进行数据分析,在之前的版别中已经对接口恳求速度添加了埋点,计算规则是接口开始恳求前,到三个接口都恳求结束的时刻,来核算恳求接口总计耗费的时刻。由于做了数据缓存后,刚进入页面时不需要恳求完数据再展现页面,而是直接从本地读取数据。所以,这个计算点的数值根本为0

我认为,优化后应该重视的,应该是缓存匹配度的问题。假如用本地数据改写页面后,网络数据和本地匹配,进入页面后没有reload也便是二次改写的问题,这样关于用户体会便是好的,优化目的也是为了有更好的用户体会。

参考链接

iOS 页面烘托 – UIView & CALayer 郭燿源大神