锻炼吃饭的家伙

优化

2021-02-03  本文已影响0人  生产八哥

系统作的内存方面优化

NSString

NSString的类型分为三种:

taggedPoint小地址是值和指针地址放在了一起,存储在常量区,不进行retain,release管理,能够直接释放回收,效率更高,创建更快,将近100倍。iOS14之后还对taggedPointer进行了混淆。最高位是taggedPointer的类型。

NSString * te = @“12345678912111”;这种方式不论多长,都是__NSCFConstantString类型,存储在常量区。

通过stringWithFormatalloc并且长度小于一定值时才为taggedPoint。

Nonpointer_isa

SideTables即散列表,散列表中主要有引用计数表弱引用表
Nonpointer_isa为非指针类型的isa,可以存储更多类的信息,包括引用计数。当引用计数存储到一定值时,并不会再存储到Nonpointer_isa的位域的extra_rc中,而是会存储到引用计数表中。

id objc_retain(id obj)
{
    if (obj->isTaggedPointerOrNil()) return obj;
    return obj->retain();
}

ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, objc_object::RRVariant variant) 

通过对retain源码的分析可以得知:

当引用计数存储到一定值时,并不会再存储到Nonpointer_isa的位域的extra_rc中,而是除以二(RC_HALF)的部分存储到SideTables 散列表中。

SideTables是一个真机长度8个元素,其他64个长度的hash数组,本质是一个哈希表,集合了数组和链表的长处,增删改查都比较方便,里面存储了SideTable。SideTables的hash键值就是一个对象obj的address。为什么sidetables最多有8张呢,而不是一张呢? 因为所有对象的引用技术全放在一张散列表中不安全,假如只访问一个而可以拿到全部的,并且每次访问都要加锁解锁,对于性能和安全性都不高,所以分了8张表。

retain做了什么:简单回答是计数值加1,更加底层应该这么回答

  1. 首先判断是不是taggedpointer,如果是,就立马返回。因为taggedpointer不需要内存管理,taggedPointer存储在常量区。
  2. 判断如果 不是nonpointerisa直接操作散列表sidetable的引用计数,每次操作散列表会开锁等耗时耗性能,所以表会有多张,为的是更安全。
  3. 如果 是nonpointerisa,表示isa联合体开启了指针优化,isa可以存储更多信息,那么就操作isa.bits中的extra_rc的引用计数+1。
  4. 如果extra_rc满了,就和散列表对半开,各存一半,以提高性能。//这么操作的目的在于提高性能,因为如果都存在散列表中,当需要release-1时,需要去访问散列表,每次都需要开解锁,比较消耗性能。extra_rc存储一半的话,可以直接操作extra_rc即可,不需要操作散列表,性能会提高很多。
  5. alloc创建的nopointerisa对象引用计数为0,包括sideTable, uintptr_t rc = 1 + bits.extra_rc;所以对于alloc来说,是 0+1=1,这也是为什么通过retaincount获取的引用计数为1的原因。alloc创建的对象实际的引用计数为0,其引用计数打印结果为1,是因为在底层rootRetainCount方法中,引用计数默认+1了,但是这里只有对引用计数的读取操作,是没有写入操作的,简单来说就是:为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作。实际上在extra_rc中的引用计数仍然为0。
  6. 在dealloc流程汇总会判断是否有isa、cxx、关联对象、弱引用表、引用计数表。

引用计数分别保存在isa.extra_rcsidetable中,当isa.extra_rc溢出时,将一半计数转移至sidetable中,而当其下溢时,又会将计数转回。当二者都为空时,会执行释放流程 。


项目可以进行的优化

界面优化:

冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
热启动:如果把 App 进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在。

main函数之前的阶段pre-main阶段的启动时间其实就是dyld加载过程的时间。针对main函数之前的启动时间,苹果提供了内建的测量方法,在Edit Scheme -> Run -> Arguments ->Environment Variables添加环境变量 DYLD_PRINT_STATISTICS 设为 1),然后运行就能看到打印耗时的日志了。

Total pre-main time: 2.7 seconds (100.0%)
         dylib loading time: 450.07 milliseconds (15.6%)   主要是`加载`动态库
        rebase/binding time: 210.8 milliseconds (8.5%)  (偏移修正/符号绑定耗时) ALSR rebase /binding符号 (偏移地址+ALSR = 运行时执行地址)   外部`绑定`的动态库越多越耗时
            ObjC setup time:  921.22 milliseconds (33.5%)   (OC类注册的耗时):OC类越多,越耗时。Swift耗时就会少很多。
           initializer time:  1113.94 milliseconds (45.3%)    执行load和构造函数的耗时

pre-main阶段优化建议
苹果官方建议自定义的动态库最好不要超过6个
二进制重排:主要是大项目 二进制重排优化都适用,主要目的是将启动时刻需要调用的方法排列在一起。启动时刻会出现大量的缺页异常PageFault,当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。发生Page Fault的时候线程是被blocked。一般项目会有0.5-1s的page fault。导致Page Fault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个Page Fault变成了一个Page Fault。这就是二进制重排的核心原理。

PageFault.png
利用xcode自带工具Instrument中的SystemTrace就能看到项目的PageFault次数,即图中的File Backed Page In

那么那么多方法,哪个方法才算是启动期间调用的呢。在方法的启动耗时中,需要去 Hook objc_msgSend 来达到监控所有 ObjC 方法的目的。
hook objc_msgsend:

  1. 绝大部分Objective C的方法在编译后会走objc_msgSend,所以通过[fishhook](https://github.com/facebook/fishhook) hook这一个C函数即可获得Objective C符号。由于objc_msgSend是变长参数,所以hook代码需要用汇编来实现,对开发人员要求较高。而且也只能拿到OC 和 swift中@objc 后的方法.
  2. 所以用clang插桩来拿到所有方法,做到100%覆盖符号。进入clang官网,会有示例代码.主要是trace_pc_guard追踪各种方法、函数、block等的调用。需要在Build Settings中的Other C Flags,输入-fsanitize-coverage=trace-pc-guard,则Clang就在读代码时候生成中间代码IR时插入一行调用自己函数方法的代码,在xcode中实现对应的函数方法就可以拿到了。这属于汇编插桩
  3. dlfcn.h里的方法dladdr可以根据上面clang的函数方法返回的函数地址来获取到对应的方法名sname

细节可以参考抖音 字节

main阶段之后的优化建议


瘦身

官方 App Thinning
己方
  1. 使用频率高且小的图片放到 Asset.car 中,Asset.car 能保证其加载和渲染的速度最优。而大的图片比如背景图之类的,长宽尺寸就有上千个像素,而这种放到 Asset.car 中会大大的增加安装包的大小。
  2. 无用图片资源删除,推荐库LSUnusedResources
  3. 有用图片瘦身:图片大小超过了 100KB,你可以考虑使用 WebP;将图片转成 WebP格式,推荐iSparta.在显示图片时使用 libwebp 进行解析。WebP 在 CPU 消耗和解码时间上会比 PNG 高两倍。所以需要在性能和体积上做取舍。
  4. 对PNG图片无损压缩来优化包大小没有效果的,因为Xcode 会通过自己的压缩算法重新对图片进行处理,只能压缩其尺寸大小。Xcode 中,构建 Asset Catalog 的工具 actool 会首先对 Asset Catalog 中的 png 图片进行解码,得到 Bitmap 数据,然后再运用 actool 的编码压缩算法进行编码压缩处理。无损压缩通过变换图片的编码压缩算法减少大小,但是不会改变 Bitmap 数据。对于 actool 来说,它接收的输入没有改变,所以无损压缩无法优化 Assets.car 的大小。对于放入 Asset.car 中的图片如果图片没有半透明效果,使用 70% 的有损压缩JPEG是一个不错的方式,既能保证图片清晰度的同时获得更小的大小。
  5. 代码瘦身:LinkMap 来获得所有的代码类和方法的信息。Mach-O 文件的 __objc_selrefs、__objc_classrefs 和 __objc_superrefs可以获取用过的方法,类,父类。但是Objective-C 是门动态语言,方法调用可以写成在运行时动态调用,这样就无法收集全所有调用的方法和类,还要二次确认。例如+load方法会被系统调用,但也能检查为未使用类。推荐Appcode工具。最简单的静态分析:基于 otool dump 最终产物中的 __objc_class_list & __objc_class_refs 做差集找到未使用的 Objc 类。
  6. Assets.carMach-O是占用空间最大的两个文件。目前市场上最低支持的 iOS 系统版本一般为 iOS 9。然而,大部分 Pod 库的 Podspec 文件中指定的deployment_target(最低支持版本)由于未及时修改,依然还是 iOS 8,这就导致了这些 Pod 库中指定的 resource_bundles 在构建出 Assets.car 时,是以 iOS 8 为最低支持版本的。统一改成iOS 9这样会多出一些优化空间。
  7. 符号裁剪符号解释
  8. 减少 Block 的使用
    我们知道 Block 是一个特殊的 OC 对象。需要占用部分二进制空间来表征一个 Block 对象。所以在非必要使用 Block 的场景。去掉 Block 实现可以优化不少包大小,常见的比如 Masonry 通过 Block 实现的链式调用。由此可见越是方便开发工作量,对性能就越是一个考验。大部分问题都能转化为空间和时间的取舍问题。
实际用到了但被扫描成无用类:

* 一个类确实没有被其他地方使用, 但是本身逻辑依赖 +load 、+initialize、__attribute__((constructor)) 在启动时调用
* 通过 string 动态调用
* 抽象基类、基类等会被认为是无用类
* 通过运行时动态生成的代码引用了某个类
* 一个类专门作为通知处理类
* MTLModel 等,通过运行时消息机制 assign value 的无法通过 classref 统计
* 典型的 DI 场景。如果一个类声明遵循了某个 Protocol,外部使用的时候使用了这个 Protocol 进行方法调用

实际没用到但被认为有用到:
* 某个对象被另外一个对象引用,但是另外一个对象本身未被使用到。这时候会遗漏掉这个对象所属 Class 的检查

电量


卡顿检测

卡顿检测原理是通过子线程对主runloop添加runloopObserver监控即将休眠唤醒两个状态间的时间间隔大于2s左右则认为卡顿,这里确切的说是执行souce和进入休眠两个状态更为精确。这里通过开启一个子线程,用while代码循环持续loop,用定义一个超时2s左右的信号量,超过这个时间往下执行的时候判断信号量若不等于0则认为卡顿,然后将 BeforeSourcesAfterWaiting这两个状态区间上传调用栈,并像微信卡顿监听方案matrix那样利用退火算法,保证重复的卡顿调用栈信息不会被上传。线程数超出64 个时会导致主线程卡顿,如果卡顿是由于线程多造成的,那么就没必要通过获取主线程堆栈去找卡顿原因了,根据 matrix-iOS 的实测,每隔 50 毫秒获取主线程堆栈会增加 3% 的 CPU 占用,可以忽略不计。 卡顿的类型有线程过多、CPU满负荷、绘制过度、IO操作、抢锁

文件 dump:如果内存 dump 的堆栈跟上次捕捉到的不一样,则 dump 到文件中;否则按照斐波那契数列将检查时间递增(1,1,2,3,5,8…)直到没有遇到卡顿或卡顿堆栈不一样。这样能够避免同一个卡顿写入多个文件的情况,也能避免检测线程围着同一个卡顿空转的情况。


离屏渲染

只要裁剪(透明度/阴影)的内容需要画家算法未完成之前的内容参与就会触发offscreenrendering

正常的显示是从帧缓存区FrameBuffer去取,而当我们设置了 cornerRadius 以及 masksToBounds 进行圆角 + 裁剪+阴影+高斯模糊时,如前文所述,masksToBounds 裁剪属性会应用到所有的 sublayer 上。这也就意味着所有的 sublayer 必须要重新被应用一次圆角+裁剪,这也就意味着所有的 sublayer 在第一次被绘制完之后,并不能立刻被丢弃,而必须要被保存在 Offscreen buffer 中等待下一轮圆角+裁剪,注意这时候并没有显示到屏幕上,多个图层都在离屏缓存区等待被一一裁剪圆角(这里是每个图层都要被裁剪,并不只是某个),这也就诱发了离屏渲染。帧缓存区是展示完了就丢弃的。离屏渲染并不都是坏的,因为对于频繁显示的复杂的,离屏会提高性能效率。

上一篇 下一篇

猜你喜欢

热点阅读