阅读视图

发现新文章,点击刷新页面。

iOS pod repo push 报错 ld: file not found: libarclite_iphoneos.a 问题解决方案


theme: channing-cyan

背景

Xcode 升级 14.3 之后,在Xcode 运行项目会收到以下错误

File not found: /Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphoneos.a

项目中可以通过以下方法解决编译错误,就是在 Podfile 中,设置IPHONEOS_DEPLOYMENT_TARGET,代码如下:

post_install do |installer|
    installer.generated_projects.each do |project|
          project.targets.each do |target|
              target.build_configurations.each do |config|
                  config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
               end
          end
   end
end

但是因为项目中有一些私有组件,也引用了一些minimum deployment设置比较低的三方库,会导致我们提交私有组件时,也报如下错误

 /App-exxigwycpikvlpgzpmccfjpmuvkx/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/YYCategories.build/Objects-normal/x86_64/Binary/YYCategories
    ld: file not found: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphonesimulator.a
    clang: error: linker command failed with exit code 1 (use -v to see invocation)
 ** BUILD FAILED **
    The following build commands failed:
    Ld /Users/mengruirui/Library/Developer/Xcode/DerivedData/App-exxigwycpikvlpgzpmccfjpmuvkx/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/YYCategories.build/Objects-normal/x86_64/Binary/YYCategories normal x86_64 (in target 'YYCategories' from project 'Pods')
    (1 failure)

解决

方案一

将第三方组件克隆一份放到本地私有仓,修改 minimum deployment,然后依赖我们自己的私有组件

方案二

在这里插入图片描述

  • 打开 Mac 上的 Finder 应用程序,在菜单中找到前往 -> 前往文件夹 输入以下地址,然后前往

/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib

  • 在该目录下面查看是否有arc文件夹,如果没有该文件夹,则新建文件夹,命名为arc

  • 将下载好的libarclite_iphonesimulator.a 文件,拷贝到arc 文件夹下面 在这里插入图片描述

我给 iOS 系统打了个补丁——修复 iOS 16 系统键盘重大 Crash(附补丁源码)

image.png

📢 下载文末附件的补丁源码内置到 App 即可修复键盘 Crash

🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团客户端工程师巴乐,通过逆向分析发现了 iOS 16 系统键盘存在重大 Bug,可能导致使用到键盘的业务场景出现严重 Crash。 在支付宝 App 近期版本 10.5.16.6000 上,巴乐用汇编重新实现了一套 iOS 16 系统键盘 tryLock 方法后,问题得到完全修复,该版本上的对应 Crash 已降到 0。本文记录了该问题解决的完整过程,包括问题发现、分析、修复以及验证,欢迎查阅与交流~

背景

在蚂蚁集团内部,支付宝技术部及蚂蚁终端技术委员会联合发起了“技术挑战英雄榜”活动,通过张榜一系列技术难题,寻找那些富有激情、敢于挑战的同学,揭榜解题,攻克顽疾!

在难题榜中,有蚂蚁内部同学张榜反馈了 iOS 支付宝 App Top 1 的 iOS 16 键盘 Crash(下文可简称“键盘 Crash“),即下图 1 的 issue 1。该 Crash 量级大且持续时间长,线下不好复现又不好排查,对线上业务影响很大,急需攻坚。

本人基于对客户端运行时技术的浓厚兴趣,揭榜领题,挑战解决该 Crash。

image.png

图 1 蚂蚁内部的技术挑战英雄榜

原始信息

Crash 信息

Crash 日志关键信息如下:

Incident Identifier: 7C53A274-4184-4E38-B27E-07B4E1335277
CrashReporter Key:   
Hardware Model:      iPhone13 4
Process:             AlipayWallet [89329]
Path:                /private/var/containers/Bundle/Application/C5F00AEC-B96F-4BF1-8C9C-25B67BCA301E/AlipayWallet.app/AlipayWallet
Identifier:          com.alipay.iphoneclient
Version:             10.5.0 (10.5.0.6000)
Code Type:           ARM-64
Parent Process:      [1]
Date/Time:           2023-08-30 04:37:48 +0000
OS Version:          iPhone OS 16.6 (20G75)
Report Version:      104
Exception Type:  SIGSEGV
Exception Codes: SEGV_MAPERR at 0x2ab3106e0
Crashed Thread:  0
Thread 0 Crashed:
0   libobjc.A.dylib                 0x00000001a5183a7c _objc_retain :16 (in libobjc.A.dylib)
1   UIKitCore                       0x00000001aed4d4d4 -[UIKeyboardTaskQueue performDeferredTaskIfIdle] :32 (in UIKitCore)
2   UIKitCore                       0x00000001ae533148 -[UIKeyboardTaskQueue continueExecutionOnMainThread] :376 (in UIKitCore)
3   Foundation                      0x00000001a63e878c ___NSThreadPerformPerform :264 (in Foundation)
4   CoreFoundation                  0x00000001ac1ca128 ___CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ :28 (in CoreFoundation)
5   CoreFoundation                  0x00000001ac1d67b4 ___CFRunLoopDoSource0 :176 (in CoreFoundation)
6   CoreFoundation                  0x00000001ac15b648 ___CFRunLoopDoSources0 :340 (in CoreFoundation)
7   CoreFoundation                  0x00000001ac1710d4 ___CFRunLoopRun :828 (in CoreFoundation)
8   CoreFoundation                  0x00000001ac1763ec _CFRunLoopRunSpecific :612 (in CoreFoundation)
9   GraphicsServices                0x00000001e768c35c _GSEventRunModal :164 (in GraphicsServices)
10  UIKitCore                       0x00000001ae502f58 -[UIApplication _run] :888 (in UIKitCore)
11  UIKitCore                       0x00000001ae502bbc _UIApplicationMain :340 (in UIKitCore)
12  AlipayWallet                    0x00000001074d539c main main.m:124 (in AlipayWallet)
13  ???                             0x00000001cb6a8dec 0x0000000000000000 + 0
Thread 1:
0   libsystem_kernel.dylib          0x00000001eb0b6ca4 _mach_msg2_trap :8 (in libsystem_kernel.dylib)
...
Thread State:
     x8:0x0000000202aa4820     x9:0x0000000282d64100     lr:0x00000001aed4d548     fp:0x000000016b032700
    x10:0x0000000000000000    x12:0x0000000000ec0e80    x11:0x000000000000001f    x14:0x0100000202aaecc9
    x13:0x0000010000000100    x16:0x0000bb12ab3106c0     sp:0x000000016b0326e0    x15:0x0000000202aaecc8
    x18:0x0000000000000000    x17:0x00000002ab3106c0    x19:0x0000000283463d00   cpsr:0x0000000000001000
     pc:0x00000001a5183a7c    x21:0x0000000000000001    x20:0x0000000000000000     x0:0x0000000286f706c0
    x23:0x0000000114841058     x1:0x0000000000000000    x22:0x000000028312e2c0     x2:0x0000000000000000
    x25:0x0000000000000002     x3:0x00000002041bc480    x24:0x0000000000000000     x4:0x0000000000000000
    x27:0x00000000211200d5     x5:0x0000000000000001    x26:0x0000000000000000     x6:0x00000001b55fb2c5
     x7:0x00000001b55fb2b9    x28:0x0000000000000001
Binary Images:
0x0000000104dcc000 - 0x000000010f6f3fff AlipayWallet arm64  <fa235f8a8e253b4d81e7e6a4fecdd4c6> /private/var/containers/Bundle/Application/C5F00AEC-B96F-4BF1-8C9C-25B67BCA301E/AlipayWallet.app/AlipayWallet
...
0x00000001a5180000 - 0x00000001a51c3f9f libobjc.A.dylib arm64e  <eb7faf215c9f37848907affa6d92bc3b> /usr/lib/libobjc.A.dylib
...
0x00000001ae166000 - 0x00000001af98afff UIKitCore arm64e  <7d57a1d1856f338d97db880c4ec8b02e> /System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore
...

提取 Crash 关键信息(后续分析基于该信息):

  • 摘要信息:iPhone 12 Pro Max(Hardware Mode: iPhone13 4)、iOS 16.6、支付宝App 10.5.0.6000 版本、Crash 直接原因是读内存地址0x2ab3106e0异常(一般读内存报错为SEGV_MAPERR,写内存报错为EXC_BAD_ACCESS
  • Crash 关键函数:0x00000001a5183a7c _objc_retain0x00000001aed4d4d4 -[UIKeyboardTaskQueue performDeferredTaskIfIdle]0x00000001ae533148 -[UIKeyboardTaskQueue continueExecutionOnMainThread]
  • Thread State:通用寄存器和浮点寄存器快照,用于查看运行时变量值及更深入的逻辑推测;
  • Binary Images:各 Image (运行时可执行指令的文件)二进制布局在内存起始位置及结束地址,起始位置可做基准,可用于计算 Crash 时的某指令地址相对于所属 Image 起始地址的偏移。

量级及分布

键盘 Crash 日 PV 一直处于大几百次,持续至少半年多,从操作系统版本分布来看仅在 iOS 16 上出现(覆盖所有机型)。 image.png

图 2 键盘 Crash 日 PV 趋势图

image.png

图 3 键盘 Crash 在不同机型及操作系统的量级分布

信息小结

从 Crash 日志栈顶的objc_retain函数关键字和量级分布情况来看,该 Crash 很可能是由 iOS 16 系统键盘控件的内存管理异常导致

分析推演

下文分析推演涉及的知识点或技能:

  1. 使用软件:Sublime Text、Xcode 及自带的lldb命令,包括bcbtframe selectdiimage listp/xpox/1b
  2. 汇编能力:Arm64 寄存器说明Arm64 汇编指令集说明;
  3. 脚本工具:otool、自研脚本fetch_class_text_from_all.sh
  4. 关键类:UIKeyboardTaskQueue 键盘核心类、NSConditionLock条件状态锁(具体使用见官方文档);
  5. 依赖模块:蚂蚁自研的DebugKit.framework(后续考虑对外输出)调试模块。

一、看现场,从 Crash 点开始

——计算 Crash 函数的偏移

因 iOS 运行时加载到内存的 Image 的起始地址是动态的(对应 Binary Images 列表中的起始地址),但某指令地址与所属 Image 的起始地址的偏移是固定的,所以可根据该偏移来查看 Crash 时是哪条指令。

  • 0x00000001a5183a7c _objc_retain所属的libobjc.A.dylib的起始地址是0x00000001a5180000,所以相对偏移 = 0x00000001a5183a7c - 0x00000001a5180000 = 0x3a7c
  • 0x00000001aed4d4d4 -[UIKeyboardTaskQueue performDeferredTaskIfIdle]所属的UIKitCore的起始地址是0x00000001ae166000,所以相对偏移 = 0x00000001aed4d4d4 - 0x00000001ae166000 = 0xbe74d4

二、模拟现场,寻找蛛丝马迹

—— Xcode 设置断点模拟现场

  1. 为模拟与 Crash 时一样的现场,需找一台与 Crash 日志中一致的设备,即 iOS 16.6 的iPhone 12 Pro Max(Hardware Mode: iPhone13 4),只有这样在下文中断点时的函数栈以及各函数偏移对应的指令才能与 Crash 日志中的完全对上。
  2. 将找到的设备与 Mac 连接并用 Xcode 启动 App(可用下文附件中 Demo 关键代码调试)。
  3. 从上述计算出的关键函数的偏移加上所属 Image 的起始地址,模拟出 Crash 时运行的函数栈,具体操作如下图 4。

image.png

图 4 设置断点模拟现场

从图 4 的第 11 步可知 Crash 的直接原因是 objc_retain 的对象野指针了,导致读取内存异常而触发 Crash。

image.png

图 5 查看上一层函数栈

从图 5 可知两点:

  1. 先后调用关系是-[UIKeyboardTaskQueue performDeferredTaskIfIdle] -> -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]该函数在 Crash 函数栈中未出现,所以只有模拟现场才能发现)-> objc_retain
  2. UIKeyboardTaskQueue类有个NSMutableArray类型的成员变量持有UIKeyboardTaskEntry对象(从图 5 中第 8 步的输出得出),而 Crash 的直接原因就是获取该数组index = 0UIKeyboardTaskEntry对象后,执行objc_retain该对象 Crash ,所以异常的原因需要从对该数组的读写排查。

小结:UIKeyboardTaskQueue类的NSMutableArray类型的成员变量是关键数组(在实例对象偏移0x20的位置),怀疑是多线程读写该数组导致的。那么该成员变量名是啥,UIKeyboardTaskQueue类又是如何保证安全使用该数组的呢?

三、全面排查,收集更多信息

——获取UIKeyboardTaskQueue类的全部信息

借助蚂蚁自研的DebugKit.framework调试模块可在运行时导出UIKeyboardTaskQueue类所有的实例方法、类方法、propertyivars成员变量。

image.png

图 6 获取 UIKeyboardTaskQueue 类的基础信息

从图 6 可知两点:

  1. UIKeyboardTaskQueue的成员变量_deferredTasks的类型是NSMutableArray(在实例对象起始地址偏移0x20的位置,从图 6 中第 6 点可知)就是上述提到关键数组。野指针一般是有多线程读写对象导致的,对_deferredTasks数组读写时应该是有锁来控制的,该类中类型为NSConditionLock的成员变量_lock(在实例对象偏移0x10的位置,从图 6 中第 5 点可知)与_deferredTasks是啥关系?
  2. 发现该类的property列表只有executionContextactiveOriginator,不包含deferredTaskslock,所以对_deferredTasks_lock(类的成员变量名一般是在property名前多加前缀“_”)的所有读写全在该类中,不存在其他类直接引用,也就是 Crash 相关的全部逻辑都在UIKeyboardTaskQueue类中,所以破案的边界也划清楚了,圈定范围。将UIKeyboardTaskQueue类的所有方法的汇编都导出来查看。

image.png

图 7 获取 UIKeyboardTaskQueue 类的所有方法实现

图 7 中第 2 步涉及的fetch_class_text_from_all.sh见下文附件中脚本源码。 小结:通过分析圈定排查范围在UIKeyboardTaskQueue类内,借助脚本可一键导出其所有方法的汇编,为进一步研究_deferredTasks_lock的关系做基础。

四、理清关系,找到突破口

—— 研究_deferredTasks_lock关系

理清以下重要的两个关系:

  1. _deferredTasks角度:UIKeyboardTaskQueue类对_deferredTasks的多线程读写是如何保证安全的,哪些方法有用到,与_lock又是什么关系?
  2. _lock角度:UIKeyboardTaskQueue类对_lock又是如何使用的,哪些方法有用到,加锁和解锁是否配对?

deferredTasks 角度

图 7 第 2 步导出的UIKeyboardTaskQueue的所有方法实现都是汇编的,为理清对_deferredTasks对象的所有读写有哪些指令,分别在哪些方法中(UIKeyboardTaskQueue实例对象偏移0x20的位置,该地址下存储的 8 字节地址才是_deferredTasks对象),需要在文件中全文搜索正则表达式x.{1,2}, #0x20筛选出所有引用_deferredTasks的指令以及所属方法,操作如下图 8(Sublime Text)。

image.png

图 8 全文搜索正则表达式的样例

在汇编层面,面向对象语言中方法的第一个入参是self(C++ 称this,Objective-C 称self),存放在x0寄存器上,所以仅筛选出偏移是从方法入参时的x0x0备份(如mov x19, x0x19就是备份了x0的值)开始的,最后整理出所有UIKeyboardTaskQueue_deferredTasks有引用并读写的指令及所属方法,如下。 注:

  • 一般面向过程语言的代码块称为函数,而面向对象语言的代码块称为方法,为避免文章的混用造成困扰,这里特别说明。
  • 下列部分的“读”或“写”是指获取到_deferredTasks对象后,对该对象是读操作还是写操作。
-[UIKeyboardTaskQueue isEmpty]:
...
0000000189c816a4ldrx0, [x19, #0x20]     读
0000000189c816a8bl_objc_msgSend$count
...

-[UIKeyboardTaskQueue finishExecution]:
...
00000001894677a8ldrx0, [x19, #0x20]     读
00000001894677acbl_objc_msgSend$count
...

-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]:
...
0000000189c8152cldrx0, [x0, #0x20]      读
0000000189c81530bl_objc_msgSend$count
0000000189c81534cbzx0, 0x189c81518
0000000189c81538ldrx0, [x19, #0x20]     读
0000000189c8153cmovx2, #0x0
0000000189c81540bl"_objc_msgSend$objectAtIndex:"
0000000189c81544bl0x18c9deec0          Crash在这行
...
0000000189c81558ldrx0, [x19, #0x20]     写:删除item
0000000189c8155cmovx2, #0x0
0000000189c81560bl"_objc_msgSend$removeObjectAtIndex:"
...

-[UIKeyboardTaskQueue continueExecutionOnMainThread]:
...
0000000189467130ldrx0, [x19, #0x20]     读
0000000189467134bl_objc_msgSend$count
...

-[UIKeyboardTaskQueue waitUntilAllTasksAreFinished]:
...
000000018952a810ldrx0, [x19, #0x20]     读
000000018952a814bl_objc_msgSend$count
...

-[UIKeyboardTaskQueue addDeferredTask:]:
...
0000000189c81640ldrx0, [x19, #0x20]     写:添加item
0000000189c81644ldrx2, [sp, #0x8]
0000000189c81648bl"_objc_msgSend$addObject:"
...

-[UIKeyboardTaskQueue init]:
...
0000000189543024ldrx8, [x19, #0x20]     读
0000000189543028strx0, [x19, #0x20]     写:创建数组实例
...

-[UIKeyboardTaskQueue .cxx_destruct]:
...
0000000189c817f4addx0, x19, #0x20       写:销毁
0000000189c817f8movx1, #0x0
0000000189c817fcbl0x18a1a4c64 ; symbol stub for: _objc_storeStrong
...

_deferredTasks的方法有 6 个:

  1. -[UIKeyboardTaskQueue isEmpty]
  2. -[UIKeyboardTaskQueue finishExecution]
  3. -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]
  4. -[UIKeyboardTaskQueue continueExecutionOnMainThread]
  5. -[UIKeyboardTaskQueue waitUntilAllTasksAreFinished]
  6. -[UIKeyboardTaskQueue init]

写_deferredTasks的方法有 4 个:

  1. -[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]
  2. -[UIKeyboardTaskQueue addDeferredTask:]
  3. -[UIKeyboardTaskQueue init]
  4. -[UIKeyboardTaskQueue .cxx_destruct]

_lock 角度

在文件中全文搜索正则表达式x.{1,2}, #0x10筛选出所有引用_lock的指令以及所属方法,操作类似上述的_deferredTasks

从上可知,UIKeyboardTaskQueue类对_lock的使用封装成 4 个方法(忽略init创建和.cxx_destruct销毁的两个方法,该两方法不会有并发问题),也就是方法使用_lock必定会调用这 4 个方法。

解锁方法有 1 个:

  1. -[UIKeyboardTaskQueue unlock]

加锁方法有 3 个:

  1. -[UIKeyboardTaskQueue lock]
  2. -[UIKeyboardTaskQueue lockWhenReadyForMainThread]
  3. -[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]

串联关系,发现 Bug

串联上述_deferredTasks_lock两个角度的方法调用(忽略init创建和.cxx_destruct销毁的两个方法),从原汇编的关键方法中列出简版的关系描述,如下图 9。

image.png

图 9 串联 _deferredTasks 和 _lock 的关系

为方便理清锁的对应关系,图 9 中用红色表示加锁,绿色表示解锁,从中可知:

  1. _deferredTasks的关键读写的方法内是有 1 个加锁和 1 个解锁对应的,预期是多线程下保护读写的安全性;
  2. 即使不读写_deferredTasks的方法内上也是有 1 个加锁和 1 个解锁对应的,用于多线程下保护其他成员变量的读写安全性;
  3. 发现问题,有 Bug-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法内的0000000189466ff8bl_objc_msgSend$tryLockWhenReadyForMainThread这行指令执行是返回BOOL类型的,即加锁成功为YES,加锁失败为NO。(参看图 6 中-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]的方法签名为typeEncoding=B16@0:8,即返回为BOOL类型);如该行指令尝试加锁但失败了,不会直接return,还会继续执行红色框内的指令并做解锁操作,会导致多线程下UIKeyboardTaskQueue类的加锁和解锁的功能不配对,也就存在锁失效的情况。

小结:-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法内有 Bug,导致存在锁失效的情况,猜测在多线程下并发读写_deferredTasks时就会偶现 Crash。

五、重新推演,确定根因

推演图

image.png

图 10 重新推演键盘 Crash 过程

按时间轴重新推演 Crash 过程:

  • T0:Thread A加锁成功后执行指令bl _objc_msgSend$addObject:添加对象A到数组_deferredTasks。同时,因为Main Thread执行指令bl _objc_msgSend$tryLockWhenReadyForMainThread失败后继续执行指令bl _objc_msgSend$unlock,使得Thread B也加锁成功后执行指令bl _objc_msgSend$addObject:添加对象B到数组_deferredTasks,导致出现多线程同时写入数组_deferredTasks的异常情况
  • T1:Thread A解锁后,Main Thread-[UIKeyboardTaskQueue performDeferredTaskIfIdle]方法内加锁成功后,在-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]方法内执行指令bl _objc_msgSend$objectAtIndex:后获取数组inde = 0的对象地址时,因多线程写入导致该对象地址被异常破坏而出现野指针(野指针存入x0寄存器)。
  • T2:Main Thread继续执行下一条指令bl _objc_claimAutoreleasedReturnValue会间接触发了_objc_retain并透传x0寄存器的值,最终在该函数内执行指令ldr x17, [x17, #0x20]Crash 了。

注:不同语言的编译器对应的符号名的生成规则是不同的,C 语言只是在原函数名前加一个前缀“_”,如objc_retain(A),编译后符号名是_objc_retain,而 C++ 语言会根据方法名加上参数名生成的符号名,如__ZNSt3__16vectorIdNS_9allocatorIdEEEixB6v15006Em

模拟 Crash

按推演的逻辑用本地 Xcode 重新起个 Demo 验证下(可用下文附件中 Demo 关键代码),通过调用[self test_crash]可模拟出 tryLock 失败时导致的 Crash(如调用[self test_ok]就不会出现 Crash),现场如下。

image.png

图 11 模拟 tryLock 加锁失败而导致的 Crash

从 Xcode 的 Console 控制台的日志中可以看到出现多线程并发添加到_deferredTasks数组的情况,在后续removeEntry_crash方法内出现了objc_retain野指针对象导致的 Crash,与上述推演的逻辑相符。

对比不同 iOS 版本

image.png

图 12 对比不同 iOS 版本的实现

通过对比发现仅 iOS 16 上有问题,iOS 15 或 iOS 17 上 tryLock 失败后都会立即return的,也就是为什么 Crash 仅出现在 iOS 16 的原因。从中我们可以看出在 iOS 17 上苹果技术同学也发现了该 Bug 并做了修复

给苹果反馈 Bug

该问题已提交至苹果“反馈助理”(图 13),但截至目前未得到其官方的 iOS 16 上的解决方案。

image.png

图 13 “反馈助理”截图

六、总结根因

通过上述分析推演,iOS 16 键盘 Crash 根因已查明,即-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法内执行-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]尝试加锁失败后,不return继续向下执行读写不安全内存以及解锁,导致存在锁失效的情况,使得UIKeyboardTaskQueue成员变量_deferredTasks数组在多线程下出现并发添加UIKeyboardTaskEntry实例而引起野指针,导致最终 Crash。

注:该根因除了导致数组读写异常而 Crash,也可能导致其他变量的状态不一致性,只是不一定表现为 Crash 而已,建议用本文方案修复。

解决方案(App 内置补丁源码)

明确根因后,解决方案就比较明确了,写一个 App 内置补丁代码使得-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法内执行-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]尝试加锁失败后,正常return即可。 补丁方案有两个:

  1. 重写-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法。在原汇编基础上新增一条指令,即在bl _objc_msgSend$tryLockWhenReadyForMainThread后添加一条汇编指令cbz w0, return_labelreturn_label对应源码return对应的汇编指令地址),如失败则return。但该方案涉及的原汇编指令较多,有 95 条汇编指令(见下文附件中 iOS 系统汇编),容易踩坑。
  2. 重写-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]方法。在该方法内如加锁失败则模拟两次return,回到-[UIKeyboardTaskQueue continueExecutionOnMainThread]的上一个函数栈,改造的汇编指令较少,安全性较好,也确认了除-[UIKeyboardTaskQueue continueExecutionOnMainThread]调用外,无其他方法调用-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]

最终,支付宝 App 基于稳定性的考虑,采用第 2 种补丁方案修复键盘 Crash。

补丁原理

image.png

图 14 修复键盘 Crash 的补丁原理

-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]实现以下逻辑:

  • 如加锁成功,则return 1 次,返回到-[UIKeyboardTaskQueue continueExecutionOnMainThread]方法的下一条指令继续执行;
  • 如加锁失败,则模拟return 2 次,返回到-[UIKeyboardTaskQueue continueExecutionOnMainThread]的函数栈的上一层函数的地址继续执行,也就是模拟了从-[UIKeyboardTaskQueue continueExecutionOnMainThread]中执行return操作。

源码return语句,对应汇编的 4 步:

  1. 恢复fplr寄存器。fp(也称x29)记录当前帧的内存地址,lr(也称x30)记录从当前函数返回时跳转到哪个地址继续执行。运行时就是通过fplr寄存器,输出线程的函数栈的。如 Crash 函数栈,或从lldbbt输出的函数栈;
  2. 恢复callee-saved寄存器。即x19-x28的寄存器,try-catch的实现就涉及该类寄存器,一般按需执行;
  3. 恢复sp寄存器。sp记录当前帧的栈顶地址,,当前函数的局部变量所在的内存地址就在(fp, sp]之间;
  4. 执行ret指令。执行ret指令后,pc就指向lr寄存器的值,然后继续执行;

本文补丁方案的原理中,tryLock 失败时就是通过:恢复fplr寄存器 + 恢复callee-saved寄存器 + 恢复sp寄存器 + 再次恢复fplr寄存器 + 再次恢复callee-saved寄存器 + 再次恢复sp寄存器 + ret指令 来模拟在-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]方法内return 2 次直接返回到-[UIKeyboardTaskQueue continueExecutionOnMainThread]的函数栈的上一层函数的。

补丁实现

有两部分组成:

  1. 重写方法:对应 fix_UIKeyboardTaskQueue.S 文件;
  2. Hook 入口:对应 fix_UIKeyboardTaskQueue.m 文件;

重写方法

重写-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]方法实现,对应下文附件中补丁源码的 fix_UIKeyboardTaskQueue.S 文件。

image.png

图 15 重写 -[UIKeyboardTaskQueue tryLockWhenReadyForMainThread] 方法实现

Hook 入口

借助+ (void)load方法在 App 启动时执行的特点实现对-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]方法的 Hook,仅在 iOS 16 的 Arm64 架构上生效,对应下文附件中补丁源码的 fix_UIKeyboardTaskQueue.m 文件。

image.png

图 16 Hook 入口的代码

方案效果

于 2023.8.25 在支付宝 App 近期版本 10.5.16.6000 上全量开启解决方案的开关后,该版本上的 Crash 日 PV 已经降到 0 了

image.png

图 17 支付宝 App 近期版本 10.5.16.6000 上键盘 Crash 日 PV

同时,支付宝 App 的全量版本(包括所有历史版本)的键盘 Crash 日 PV 下降了近 90%,随着更多用户升级到支付宝 App 最新版本,预计会降到个位数。

image.png

图 18 方案上线后键盘 Crash 日 PV 明显下降的趋势图

最终该方案由验收人确认有效,键盘 Crash 已解决,揭榜挑战成功,附上一张挑战成功捷报图收个尾。

image.png

图 19 蚂蚁内部的技术英雄榜捷报

附件

1、补丁源码

补丁源码包括两部分:fix_UIKeyboardTaskQueue.S 和 fix_UIKeyboardTaskQueue.m。 使用时将该两文件直接内置在 App 中即可,也可在 App 启动时加开关控制 Hook 入口的时机。

#ifdef __arm64__
//
//  fix_UIKeyboardTaskQueue.S
//  fix_UIKeyboardTaskQueue
//
//  Created by Alipay on 2023/8/10.
//  Copyright © 2023 Alipay. All rights reserved.
//

/**
 原实现
 -[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]:
    ldr    x0, [x0, #0x10]
    mov    x2, #0x0
    b    "_objc_msgSend$tryLockWhenCondition:"
 */

// 重写实现
.section    __TEXT,__cstring,cstring_literals
tryLockWhenCondition.str:
.asciz      "tryLockWhenCondition:"

.text
.align 4
.global _fix_UIKeyboardTaskQueue_tryLockWhenReadyForMainThread
.cfi_startproc
_fix_UIKeyboardTaskQueue_tryLockWhenReadyForMainThread:
stp    x20, x19, [sp, #-0x20]!
stp    x29, x30, [sp, #0x10]
add    x29, sp, #0x10
mov    x19, x0                     ; self
adrp   x0, tryLockWhenCondition.str@PAGE
add    x0, x0, tryLockWhenCondition.str@PAGEOFF
bl     _sel_registerName           ; @selector(tryLockWhenCondition:)
mov    x1, x0
ldr    x0, [x19, #0x10]            ; _lock
mov    x2, #0x0
bl     _objc_msgSend               ; -[_lock tryLockWhenCondition:0]
ldp    x29, x30, [sp, #0x10]       ; 恢复fp和lr
ldp    x20, x19, [sp], #0x20       ; 恢复callee-saved寄存器、并恢复sp
cbz    x0, 1f
// 如tryLock成功,则继续执行-[UIKeyboardTaskQueue continueExecutionOnMainThread]的指令
ret

// 如tryLock失败,则模拟从-[UIKeyboardTaskQueue continueExecutionOnMainThread] return,不再继续执行
1:
ldp    x29, x30, [sp, #0x20]       ; 恢复fp和lr
ldp    x20, x19, [sp, #0x10]       ; 恢复callee-saved寄存器
add    sp, sp, #0x30               ; 恢复sp
autibsp                            ; Authenticate Instruction address
ret
.cfi_endproc
#endif
//
//  fix_UIKeyboardTaskQueue.m
//  fix_UIKeyboardTaskQueue
//
//  Created by Alipay on 2023/9/4.
//
#ifdef __arm64__

#import <UIKit/UIKit.h>
#include <objc/runtime.h>

@interface fix_UIKeyboardTaskQueue : NSObject
@end

@implementation fix_UIKeyboardTaskQueue
+ (void)load {
    extern BOOL fix_UIKeyboardTaskQueue_tryLockWhenReadyForMainThread(id self, SEL selector);
    if (@available(iOS 16.0, *)) {
        NSString *systemVersion = [[UIDevice currentDevice] systemVersion];
        NSArray *verInfos = [systemVersion componentsSeparatedByString:@"."];
        NSUInteger count = [verInfos count];
        if (count >= 2) {
            if ([verInfos[0] isEqualToString:@"16"]) {
                class_replaceMethod(objc_getClass("UIKeyboardTaskQueue"), sel_getUid("tryLockWhenReadyForMainThread"), (IMP)fix_UIKeyboardTaskQueue_tryLockWhenReadyForMainThread, "B16@0:8");
            }
        }
    }
}
@end

#endif

2、Demo 关键源码

//
//  ViewController.m
//  UIKeyboardTaskQueueDemo
//
//  Created by Alipay on 2023/8/30.
//

#import "ViewController.h"
#include <objc/runtime.h>
// #import <DebugKit/DebugKit.h>

@interface ViewController ()

@end

@implementation ViewController {
    NSMutableArray *_tasks;
    NSMutableArray *_deferredTasks;
    NSConditionLock *_lock;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 输出UIKeyboardTaskQueue的所有实例方法和类方法
    // dk_print_all_methods_of_class("UIKeyboardTaskQueue");
    // 输出UIKeyboardTaskQueue的所有property
    // dk_print_all_properties("UIKeyboardTaskQueue");
    // 输出UIKeyboardTaskQueue的所有ivars
    // dk_print_class_all_ivars("UIKeyboardTaskQueue");
    
    UITextView *textView = [[UITextView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:textView];
    
    [self test_crash];
    [self test_ok];
}

- (void)unlock {
    [_lock unlockWithCondition:0];
}

- (void)lock {
    [_lock lock];
}

- (BOOL)tryLock {
    return [_lock tryLockWhenCondition:0];
}

- (void)addEntry_ok {
    [self lock];
    [_deferredTasks addObject:[[NSObject alloc] init]];
    NSLog(@"add,    %lu", _deferredTasks.count);
    [self unlock];
}
- (void)removeEntry_crash {
    [self tryLock];
    if (_deferredTasks.count) {
        [_tasks addObject:[_deferredTasks objectAtIndex:0]];
        if (_deferredTasks.count) {
            [_deferredTasks removeObjectAtIndex:0];
            NSLog(@"remove, %lu", _deferredTasks.count);
//            NSLog(@"%@, %lu -[_deferredTasks removeObjectAtIndex:0]", [NSThread currentThread], _deferredTasks.count);
        }
    }
    [self unlock];
}
- (void)removeEntry_ok {
    if (![self tryLock]) return;
    if (_deferredTasks.count) {
        [_tasks addObject:[_deferredTasks objectAtIndex:0]];
        if (_deferredTasks.count) {
            [_deferredTasks removeObjectAtIndex:0];
            NSLog(@"remove, %lu", _deferredTasks.count);
//            NSLog(@"%@, %lu -[_deferredTasks removeObjectAtIndex:0]", [NSThread currentThread], _deferredTasks.count);
        }
    }
    [self unlock];
}

- (void)test_crash {
    // init
    _tasks = [NSMutableArray array];
    _deferredTasks = [NSMutableArray array];
    _lock = [[NSConditionLock alloc] initWithCondition:0];
    
    for (int i = 0; i < 10000; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self addEntry_ok];
        });
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
//            dispatch_async(dispatch_get_main_queue(), ^{
                [self removeEntry_crash];
//            });
        });
    }
}

- (void)test_ok {
    // init
    _tasks = [NSMutableArray array];
    _deferredTasks = [NSMutableArray array];
    _lock = [[NSConditionLock alloc] initWithCondition:0];
    
    for (int i = 0; i < 1000; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self addEntry_ok];
        });
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
            [self removeEntry_ok];
        });
    }
}
@end

3、脚本源码

#!/bin/sh

# File
TEXT_FILE="$1";

# Class
CLASS_NAME="$2"; 
cat "$TEXT_FILE" | tr '\n' '&' | sed 's/&\-\[/\n\-\[/g'|grep "^\-\[$CLASS_NAME " | tr '&' '\n';

4、iOS 系统汇编(关键方法)

将 iOS 16.6 的 iPhone 12 Pro Max(Hardware Mode: iPhone13 4)设备连接到 Xcode 后,按如下操作可获取到 UIKeyboardTaskQueue 类的实现汇编,即UIKitCore_20G75_arm64e_TEXT.txt 文件。

otool -s __TEXT __text -v ~/Library/Developer/Xcode/iOS\ DeviceSupport/16.6\ \(20G75\)\ arm64e/Symbols/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore > ~/Desktop/UIKitCore_20G75_arm64e_TEXT.txt
./fetch_class_text_from_all.sh ~/Desktop/UIKitCore_20G75_arm64e_TEXT.txt UIKeyboardTaskQueue > ~/Desktop/UIKeyboardTaskQueue_20G75_arm64e_TEXT.txt
-[UIKeyboardTaskQueue continueExecutionOnMainThread]:
0000000189466fd0pacibsp
0000000189466fd4subsp, sp, #0x30
0000000189466fd8stpx20, x19, [sp, #0x10]
0000000189466fdcstpx29, x30, [sp, #0x20]
0000000189466fe0addx29, sp, #0x20
0000000189466fe4movx19, x0
0000000189466fe8bl0x18c9df5e0
0000000189466feccmpw0, #0x1
0000000189466ff0b.ne0x189467024
0000000189466ff4movx0, x19
0000000189466ff8bl_objc_msgSend$tryLockWhenReadyForMainThread
0000000189466ffcldrx8, [x19, #0x28]
0000000189467000cbzx8, 0x189467058
0000000189467004ldrx8, [x19, #0x30]
0000000189467008cbzx8, 0x1894670b4
000000018946700cbl0x18c9df2f0
0000000189467010strx0, [sp, #0x8]
0000000189467014ldrx8, [x19, #0x30]
0000000189467018strxzr, [x19, #0x30]
000000018946701cbl0x18c9df150
0000000189467020b0x1894670ac
0000000189467024adrpx8, -26465 ; 0x182d06000
0000000189467028addx2, x8, #0xe19
000000018946702cmovx0, x19
0000000189467030movx3, #0x0
0000000189467034movw4, #0x0
0000000189467038ldpx29, x30, [sp, #0x20]
000000018946703cldpx20, x19, [sp, #0x10]
0000000189467040addsp, sp, #0x30
0000000189467044autibsp
0000000189467048eorx16, x30, x30, lsl #1
000000018946704ctbzx16, #0x3e, 0x189467054
0000000189467050brk#0xc471
0000000189467054b"_objc_msgSend$performSelectorOnMainThread:withObject:waitUntilDone:"
0000000189467058ldrx0, [x19, #0x18]
000000018946705cbl_objc_msgSend$count
0000000189467060cbzx0, 0x1894670b8
0000000189467064adrpx8, 333019 ; 0x1da942000
0000000189467068ldrx0, [x8, #0x500]
000000018946706cbl0x18c9dee30
0000000189467070movx2, x19
0000000189467074bl"_objc_msgSend$initWithExecutionQueue:"
0000000189467078movx20, x0
000000018946707cmovx0, x19
0000000189467080movx2, x20
0000000189467084bl"_objc_msgSend$setExecutionContext:"
0000000189467088bl0x18c9df0a0
000000018946708cldrx0, [x19, #0x18]
0000000189467090movx2, #0x0
0000000189467094bl"_objc_msgSend$objectAtIndex:"
0000000189467098bl0x18c9deec0
000000018946709cstrx0, [sp, #0x8]
00000001894670a0ldrx0, [x19, #0x18]
00000001894670a4movx2, #0x0
00000001894670a8bl"_objc_msgSend$removeObjectAtIndex:"
00000001894670acldrx0, [sp, #0x8]
00000001894670b0b0x1894670b8
00000001894670b4movx0, #0x0
00000001894670b8strx0, [sp, #0x8]
00000001894670bcbl_objc_msgSend$originatingStack
00000001894670c0bl0x18c9deec0
00000001894670c4movx20, x0
00000001894670c8movx0, x19
00000001894670ccmovx2, x20
00000001894670d0bl"_objc_msgSend$setActiveOriginator:"
00000001894670d4bl0x18c9df0a0
00000001894670d8movx0, x19
00000001894670dcbl_objc_msgSend$unlock
00000001894670e0ldrx1, [sp, #0x8]
00000001894670e4ldrbw20, [x19, #0x8]
00000001894670e8movw8, #0x1
00000001894670ecstrbw8, [x19, #0x8]
00000001894670f0ldrx2, [x19, #0x28]
00000001894670f4cbzx1, 0x189467108
00000001894670f8movx0, x1
00000001894670fcbl"_objc_msgSend$execute:"
0000000189467100ldrx1, [sp, #0x8]
0000000189467104b0x18946710c
0000000189467108cbzx2, 0x189467130
000000018946710cstrbw20, [x19, #0x8]
0000000189467110ldpx29, x30, [sp, #0x20]
0000000189467114ldpx20, x19, [sp, #0x10]
0000000189467118addsp, sp, #0x30
000000018946711cautibsp
0000000189467120eorx16, x30, x30, lsl #1
0000000189467124tbzx16, #0x3e, 0x18946712c
0000000189467128brk#0xc471
000000018946712cb0x18c9df060
0000000189467130ldrx0, [x19, #0x20]
0000000189467134bl_objc_msgSend$count
0000000189467138ldrx1, [sp, #0x8]
000000018946713ccbzx0, 0x18946710c
0000000189467140movx0, x19
0000000189467144bl_objc_msgSend$performDeferredTaskIfIdle
0000000189467148b0x189467100

-[UIKeyboardTaskQueue tryLockWhenReadyForMainThread]:
0000000189467738ldrx0, [x0, #0x10]
000000018946773cmovx2, #0x0
0000000189467740b"_objc_msgSend$tryLockWhenCondition:"

-[UIKeyboardTaskQueue performDeferredTaskIfIdle]:
0000000189c814b4pacibsp
0000000189c814b8stpx20, x19, [sp, #-0x20]!
0000000189c814bcstpx29, x30, [sp, #0x10]
0000000189c814c0addx29, sp, #0x10
0000000189c814c4movx19, x0
0000000189c814c8bl_objc_msgSend$lock
0000000189c814ccmovx0, x19
0000000189c814d0bl_objc_msgSend$promoteDeferredTaskIfIdle
0000000189c814d4movx0, x19
0000000189c814d8bl_objc_msgSend$unlock
0000000189c814dcmovx0, x19
0000000189c814e0ldpx29, x30, [sp, #0x10]
0000000189c814e4ldpx20, x19, [sp], #0x20
0000000189c814e8autibsp
0000000189c814eceorx16, x30, x30, lsl #1
0000000189c814f0tbzx16, #0x3e, 0x189c814f8
0000000189c814f4brk#0xc471
0000000189c814f8b_objc_msgSend$continueExecutionOnMainThread

-[UIKeyboardTaskQueue promoteDeferredTaskIfIdle]:
0000000189c814fcpacibsp
0000000189c81500subsp, sp, #0x30
0000000189c81504stpx20, x19, [sp, #0x10]
0000000189c81508stpx29, x30, [sp, #0x20]
0000000189c8150caddx29, sp, #0x20
0000000189c81510ldrx8, [x0, #0x28]
0000000189c81514cbzx8, 0x189c81528
0000000189c81518ldpx29, x30, [sp, #0x20]
0000000189c8151cldpx20, x19, [sp, #0x10]
0000000189c81520addsp, sp, #0x30
0000000189c81524retab
0000000189c81528movx19, x0
0000000189c8152cldrx0, [x0, #0x20]
0000000189c81530bl_objc_msgSend$count
0000000189c81534cbzx0, 0x189c81518
0000000189c81538ldrx0, [x19, #0x20]
0000000189c8153cmovx2, #0x0
0000000189c81540bl"_objc_msgSend$objectAtIndex:"
0000000189c81544bl0x18c9deec0
0000000189c81548movx2, x0
0000000189c8154cstrx0, [sp, #0x8]
0000000189c81550ldrx0, [x19, #0x18]
0000000189c81554bl"_objc_msgSend$addObject:"
0000000189c81558ldrx0, [x19, #0x20]
0000000189c8155cmovx2, #0x0
0000000189c81560bl"_objc_msgSend$removeObjectAtIndex:"
0000000189c81564ldrx0, [sp, #0x8]
0000000189c81568ldpx29, x30, [sp, #0x20]
0000000189c8156cldpx20, x19, [sp, #0x10]
0000000189c81570addsp, sp, #0x30
0000000189c81574autibsp
0000000189c81578eorx16, x30, x30, lsl #1
0000000189c8157ctbzx16, #0x3e, 0x189c81584
0000000189c81580brk#0xc471
0000000189c81584b0x18c9df050

-[UIKeyboardTaskQueue addDeferredTask:]:
0000000189c815fcpacibsp
0000000189c81600subsp, sp, #0x30
0000000189c81604stpx20, x19, [sp, #0x10]
0000000189c81608stpx29, x30, [sp, #0x20]
0000000189c8160caddx29, sp, #0x20
0000000189c81610movx19, x0
0000000189c81614bl0x18c9df200
0000000189c81618movx20, x0
0000000189c8161cmovx0, x19
0000000189c81620bl_objc_msgSend$lock
0000000189c81624adrpx8, 330945 ; 0x1da942000
0000000189c81628ldrx0, [x8, #0x510]
0000000189c8162cbl0x18c9dee30
0000000189c81630movx2, x20
0000000189c81634bl"_objc_msgSend$initWithTask:"
0000000189c81638strx0, [sp, #0x8]
0000000189c8163cbl0x18c9df0a0
0000000189c81640ldrx0, [x19, #0x20]
0000000189c81644ldrx2, [sp, #0x8]
0000000189c81648bl"_objc_msgSend$addObject:"
0000000189c8164cmovx0, x19
0000000189c81650bl_objc_msgSend$unlock
0000000189c81654movx0, x19
0000000189c81658bl_objc_msgSend$continueExecutionOnMainThread
0000000189c8165cldrx0, [sp, #0x8]
0000000189c81660ldpx29, x30, [sp, #0x20]
0000000189c81664ldpx20, x19, [sp, #0x10]
0000000189c81668addsp, sp, #0x30
0000000189c8166cautibsp
0000000189c81670eorx16, x30, x30, lsl #1
0000000189c81674tbzx16, #0x3e, 0x189c8167c
0000000189c81678brk#0xc471
0000000189c8167cb0x18c9df050

lld 17 ELF changes

LLVM 17 will be released. As usual, I maintain lld/ELF and have addedsome notes to https://github.com/llvm/llvm-project/blob/release/17.x/lld/docs/ReleaseNotes.rst.Here I will elaborate on some changes.

  • When --threads= is not specified, the number ofconcurrency is now capped to 16. A large --thread= can harmperformance, especially with some system malloc implementations likeglibc's. (D147493)
  • --remap-inputs= and --remap-inputs-file=are added to remap input files. (D148859)
  • --lto= is now available to supportclang -funified-lto (D123805)
  • --lto-CGO[0-3] is now available to controlCodeGenOpt::Level independent of the LTO optimizationlevel. (D141970)
  • --check-dynamic-relocations= is now correct 32-bittargets when the addend is larger than 0x80000000. (D149347)
  • --print-memory-usage has been implemented for memoryregions. (D150644)
  • SHF_MERGE, --icf=, and--build-id=fast have switched to 64-bit xxh3. (D154813)
  • Quoted output section names can now be used in linker scripts.(#60496 <https://github.com/llvm/llvm-project/issues/60496>_)
  • MEMORY can now be used without a SECTIONScommand. (D145132)
  • REVERSE can now be used in input section descriptionsto reverse the order of input sections. (D145381)
  • Program header assignment can now be used withinOVERLAY. This functionality was accidentally lost in 2020.(D150445)
  • Operators ^ and ^= can now be used inlinker scripts.
  • LoongArch is now supported.
  • DT_AARCH64_MEMTAG_* dynamic tags are now supported. (D143769)
  • AArch32 port now supports BE-8 and BE-32 modes for big-endian. (D140201) (D140202) (D150870)
  • R_ARM_THM_ALU_ABS_G* relocations are now supported. (D153407)
  • .ARM.exidx sections may start at non-zero outputsection offset. (D148033)
  • Arm Cortex-M Security Extensions is now implemented. (D139092)
  • BTI landing pads are now added to PLT entries accessed by rangeextension thunks or relative vtables. (D148704) (D153264)
  • AArch64 short range thunk has been implemented to mitigate theperformance loss of a long range thunk. (D148701)
  • R_AVR_8_LO8/R_AVR_8_HI8/R_AVR_8_HLO8/R_AVR_LO8_LDI_GS/R_AVR_HI8_LDI_GShave been implemented. (D147100) (D147364)
  • --no-power10-stubs now works for PowerPC64.
  • DT_PPC64_OPT is now supported. (D150631)
  • PT_RISCV_ATTRIBUTES is added to include theSHT_RISCV_ATTRIBUTES section. (D152065)
  • R_RISCV_PLT32 is added to support C++ relative vtables.(D143115)
  • RISC-V global pointer relaxation has been implemented. Specify--relax-gp to enable the linker relaxation. (D143673)
  • The symbol value of foo is correctly handled when--wrap=foo and RISC-V linker relaxation are used. (D151768)
  • x86-64 large data sections are now placed away from code sections toalleviate relocation overflow pressure. (D150510)

When using glibc malloc with a largerstd::thread::hardware_concurrency (say, more than 16),parallel relocation scanning can be quite slower without the--threads=16 throttling.

I usually try to make extensions, unless too LLVM internal specific(e.g. --lto-*), accepted by the binutils community. The feature request for--remap-inputs= and --remap-inputs-file= was asuccess story, implemented by GNU ld 2.41.

PT_RISCV_ATTRIBUTES output is still not quite right. Ialso question about its usefulness. Unfortunately, at this stage, it'sdifficult to getrid of it.

This cycle has a surprising number of new features, and I have spentlots of spare time reviewing them to ensure that they are robust andproperly tested. Most stuff is completely unrelated to my day job.

There are quite a few AArch32 changes from Arm engineers, primarilyabout big-endian support and Cortex-M Security Extensions.

I was firm that the RISC-V global pointer relaxation needs to beopt-in. I had a GNU ld --relax-gp patch last year andutilitized this opportunity (ld.lld feature proposal) to move forwardGNU ld --relax-gp. It's unfortunately opt-out, but havingan option is a step forward.

This release adds support for LoongArch, which is a relatively newarchitecture that took inspiration from Mips and RISC-V.

Speed

Unlike previous versions, there is just a minor performanceimprovement compared with lld 15.0.0. I added a simplified version of64-bit xxh3 into the LLVMSupport library and utilized it inlld.

Linking a -DCMAKE_BUILD_TYPE=Debug build of clang 16:

1
2
3
4
5
6
7
8
9
10
11
12
% hyperfine --warmup 2 --min-runs 25 "numactl -C 20-27 "{/tmp/out/custom-16/bin/ld.lld,/tmp/out/custom-17/bin/ld.lld}" @response.txt --threads=8"
Benchmark 1: numactl -C 20-27 /tmp/out/custom-16/bin/ld.lld @response.txt --threads=8
Time (mean ± σ): 3.159 s ± 0.035 s [User: 7.089 s, System: 3.076 s]
Range (min … max): 3.095 s … 3.250 s 25 runs

Benchmark 2: numactl -C 20-27 /tmp/out/custom-17/bin/ld.lld @response.txt --threads=8
Time (mean ± σ): 3.131 s ± 0.027 s [User: 6.851 s, System: 3.101 s]
Range (min … max): 3.080 s … 3.198 s 25 runs

Summary
'numactl -C 20-27 /tmp/out/custom-17/bin/ld.lld @response.txt --threads=8' ran
1.01 ± 0.01 times faster than 'numactl -C 20-27 /tmp/out/custom-16/bin/ld.lld @response.txt --threads=8'

This influence to the total link time is small. However, if I testthe time proportion of the hash function in the total link time, I cansee that the proportion has been reduced to nearly one third. On someworkload and some machines this effect may be larger.

Link: lld 16 ELFchanges

iOS二进制化-Minio存储服务搭建

1.介绍

Minio是一款开源的对象存储服务器,它提供了一个简单、安全、高效的方式来存储和访问大量数据。Minio可以在各种环境中运行,包括本地服务器、云服务器和容器,并支持多种开源工具和云服务。Minio具有高度可扩展性,可以通过添加更多的存储空间来扩大存储容量,并通过添加更多的Minio实例来提高计算能力。总之,Minio是一个强大、灵活、可扩展的解决方案,可以帮助用户存储和访问大量数据。

2.服务搭建

安装和启动:

  1. 使用Homebrew安装MinIO包:打开终端,输入以下命令: brew install minio/stable/minio

  2. 全局变量配置:

  • 要先找到minio安装后的存放位置(印象中安装成功后在终端会有显示)。 这个我自己电脑的存放路径:/usr/local/Homebrew/Library/Taps/minio/homebrew

  • 然后找到目录里面的bin文件夹,把bin文件夹的路径记下来,这是我的:/usr/local/Cellar/minio/RELEASE.2023-07-18T17-49-40Z_1/bin

  • 然后进入到电脑的这个路径下:/Users/你自己的电脑名称/。把当前目录下的隐藏文件展示出来,快捷键:shift+command+.。找到.bash_profile文件。

  • 打开.bash_profile文件,把上面记下来的bin文件路径像样子加在bash_profile文件里面:export PATH=/usr/local/Cellar/minio/RELEASE.2023-07-18T17-49-40Z_1/bin:$PATH,最后command+s保存。

  1. 创建负责数据存储的文件夹:在桌面上创建一个名为minio-data的文件夹,用于存储MinIO的数据(自己喜欢放在哪都行,我是直接放桌面了)。

  2. 启动minio服务,终端输入:minio server 创建的minio-data路径。启动成功后终端打印出以下信息,红色框里的就是minio服务的地址和用户名、密码,都是默认的。因为只是本地服务而已就啥都直接用默认了,怎样配置这些我也没去弄。

Pasted Graphic 3.png

  1. minio启动成功后,在浏览器访问终端打印出来的默认地址:http://127.0.0.1:9000输入默认账号和密码,进行登录。

Pasted Graphic 4.png

创建秘钥

  • 按着下面图去创建access_keysecret_key,这两个东西要记下来。 Pasted Graphic 8.png

桶配置

  • 桶创建,直接填入桶名称其他的可以不用选

Pasted Graphic 5.png

Pasted Graphic 6.png

  • 桶创建后,需要把它改成公有桶

Pasted Graphic 7.png

以上,二进制用到的本地文件存储服务就算是搭建完成了

3.python-minio上传文件

需要下载相关的python库,终端输入:pip3 install minio

简单的使用就是这样子:

from minio import Minio

# 创建Minio客户端对象
client = Minio(
        "127.0.0.1:9000", 
        access_key="nGQSti0hmJ8mRP1vSceD",
        secret_key="EhF7jXkbKRypgexGkTR67LdSbPXXGr0capl33Wkw",
        secure=False,
               )
# 本地文件绝对路径
local_file_path = "xxxx/xxxxx/MJExtension.framework.zip"
# 桶名称
bucket_name = "ios-frameworks"
# 上传的路径
save_path = "MJExtension/3.2.4/MJExtension.framework.zip"

# 上传文件
with open(local_file_path, "rb") as file_data:
     client.fput_object(
          bucket_name, save_path,
          pod_framework_path, 
          content_type="application/zip"
         )

iOS热修复,看这里就够了(手把手教你玩热修)

背景

对于app store的审核周期不确定性,可长到2星期,短到1天。假如线上的应用出现了一些bug,甚至是致命的崩溃,这时候假如按照苹果的套路乖乖重新发布一个版本,然后静静等待看似漫无期限的审核周期,最终结果就是:用户大量流失。因此,对于一些线上的bug,需要有及时修复的能力,这就是所谓的热修复(hotfix)。

随着迭代频繁或者次数的增多,应用出现功能异常或不可用的情况也会随之增多。这时候又有什么方法可以快速解决线上的问题呢?第一、在一开始功能设计的时候就设计降级方案,但随之开发成本和测试成本都会双倍增加;第二、每个功能加上开关配置,这样治标不治本,当开关关掉的时候,就意味着用户无法使用该功能。这时候热修复就是解决这种问题的最佳之选,既能修复问题,又能让用户无感知,两全其美。iOS热修复技术从最初的webView到最近的SOT,技术的发展越来越快,新技术已经到来:

一. 首先是原理篇MangoFix:(知道原理才能更好的干活)

热修复的核心原理:

1.  拦截目标方法调用,让其调用转发到预先埋好的特定方法中
1.  获取目标方法的调用参数

只要完成了上面两步,你就可以随心所欲了。在肆意发挥前,你需要掌握一些 Runtime 的基础理论,
Runtime 可以在运行时去动态的创建类和方法,因此你可以通过字符串反射的方式去动态调用OC方法、动态的替换方法、动态新增方法等等。下面简单介绍下热修复所需要用到的 Runtime 知识点。

OC消息转发机制

由上图消息转发流程图可以看出,系统给了3次机会让我们来拯救。

第一步,在resolveInstanceMethod方法里通过class_addMethod方法来动态添加未实现的方法;

第二步,在forwardingTargetForSelector方法里返回备用的接受者,通过备用接受者里的实现方法来完成调用;

第三步,系统会将方法信息打包进行最终的处理,在methodSignatureForSelector方法里可以对自己实现的方法进行方法签名,通过获取的方法签名来创建转发的NSInvocation对象,然后再到forwardInvocation方法里进行转发。

方法替换就利用第三步的转发进行替换。

当然现在有现成的,初级及以上iOS开发工程师很快就可以理解的语法分析,大概了解一下mangofix是可以转化oc和swift代码的:具体详情请看
https://www.jianshu.com/p/7ae91a2daead

那么为什么它可以执行转化呢,转化逻辑是什么?
MangoFix项目主页上中已经讲到,MangoFix既是一个iOS热修复SDK,但同时也是一门DSL(领域专用语言),即iOS热修复领域专用语言。既然是一门语言,那肯定要有相应的编译器或者解析器。相对于编译器,使用解析器实现语言的执行,虽然效率低了点,但显然更加简单和灵活,所以MangoFix选择了后者。下面我们先用一张简单流程图,看一下MangoFix的运行原理,然后逐一解释。

image.png

1、MangoFix脚本

首先热修复之前,我们先要准备好热修复脚本文件,以确定我们的修复目标和执行逻辑,这个热修复脚本文件便是我们这里要介绍的MangoFix脚本,正常是放在我们的服务端,然后由App在启动时或者适当的运行期间进行下载,利用MangoFix提供的MFContext对象进行解析执行。对于MangoFix脚本的语法规则,这点可以参考MangoFix Quick Start,和OC的语法非常类似,你如果有OC开发经验,相信你花10分钟便可以学会。当然,在后续的文章中我可能也会介绍这一块。

2、词法分析器

几乎所有的语言都有词法分析器,主要是将我们的输入文件内容分割成一个个token,MangoFix也不例外,MangoFix词法分析器使用Lex所编写,如果你想了解MangoFix词法分析器的代码,可以点击这里

3、语法分析器

和词法分析器类似,几乎所有语言也都有自己的语法分析器,其主要目的是将词法分析器输出的一个个token构建成一棵抽象语法树,而且这颗抽象语法树是符合我们预先设计好的上下文无关文法规则的,如果你想了解MangoFix语法分析器的代码,可以点击这里

4、语义检查

由于语法分析器输出的抽象语法树,只是符合上下文无关文法规则,没有上下文语义关联,所以MangoFix还会进一步做语义检查。比如我们看下面代码:

less  
复制代码  
@interface MyViewController : UIViewController  
  
@end  
angelscript  
复制代码  
class MyViewController : BaseViewController{  
  
- (void)viewDidLoad{  
    //TODO  
}  
  
}  

上面部分是OC代码,下面部分是MangoFix代码,从文法角度MangoFix这个代码是没有问题的,但是在逻辑上却有问题, MyViewController在原来OC中和MangoFix中继承的父类不一致,这是OC runtime所不允许的。

5、创建内置对象

MangoFix脚本中很多功能都是通过预先创建内置对象的方式支持的,比如常用结构体的声明、变量、宏、C函数和GCD相关的操作等,如果想详细了解MangoFix中有哪些内置对象,可以点击这里。当然MangoFix也开放了相关接口,你也可以向MangoFix执行上下文中注入你需要的对象。

6、执行顶层语句

在做完上面的操作后,MangoFix解析器就开 始真正执行MangoFix脚本了,比如顶层语句的执行、结构体的声明、类的定义等。

7、利用runtime热修复

现在就到了最关键一步了,就是利用runtime替换掉原来Method的IMP指针,MangoFix利用libffi库动态创建C函数,在创建的C函数中调用MangoFix脚本中方法,然后用刚刚创建的C函数替换原来Method的IMP指针,当然MangoFix也会保留原有的IMP指针,只不过这时候该IMP指针对应的selector要在原有的基础上在前面拼接上ORG,这一点和JSPatch一致。当然,MangoFix也支持对属性的添加。

8、MangoFix方法执行

最后当被修复的OC方法在被调用的时候,程序会走到我们动态创建的C函数中,在该函数中我们通过查找一个全局的方法替换表,找到对应的MangoFix方法实现,然后利用MangoFix解析器执行该MangoFix的方法。

二. 具体执行(OC修复OC)。

1.后台分发补丁平台:

补丁平台:http://patchhub.top/mangofix/login

github地址:https://github.com/yanshuimu/MangoFixUtil

  1. 首先你要明白:必须得有个后台去上传,分发bug的文件,安全起见,脚本已经通过AES128加密,终端收到加密的脚本再去解密,防止被劫持和篡改,造成代码出现问题。
    登录这个补丁平台,可以快速创建appid。
    github地址下载并配合使用:
    以下是MangoFixUtil的说明:
    MangoFixUtil是对MangoFix进行了简单的封装,该库在OC项目中实战已经近2年多,经过多次迭代,比较成熟。但需要搭配补丁管理后台一起使用,后台由作者开发维护,目前有50+个已上架AppStore的应用在使用,欢迎小伙伴们使用。

  2. 举个实战中的例子:

我们快速迭代中遇到的一些问题:
image.png

有一次我们解析到后台数据从中间截取字符串,然而忘了做判空操作,后台数据一旦不给返回,那么项目立马崩溃,所以做了热修复demo.mg文件放到Patch管理平台,具体代码如OC基本一致:

class JRMineLoginHeaderView:JRTableViewHeaderView {  
  
- (NSString *)getNetStringNnm:(NSString *)str{  
    NSError *error = nil;  
    if(str.length<=0) {  
        return @"";  
    }  
      
    NSRegularExpression *regex = NSRegularExpression.regularExpressionWithPattern:options:error:(@"\d+",0,&error);  
  
    if (error) {  
        return @"";  
    } else {  
      
    if (str.length == 0) {  
        return @"";  
    }  
          
        NSArray *matches = regex.matchesInString:options:range:(str,0,NSMakeRange(0, str.length));  
        for (NSTextCheckingResult *match in matches) {  
            NSString *matchString = str.substringWithRange:(match.range);  
            return matchString;  
        }  
    }  
    return @"";  
}  
  
}  

以上代码中,新增了对象长度判空操作:  if(str.length<=0) {
        return @"";
    }
    完美的解决了崩溃的问题。

2.oc转换成DSL语言。

一切准备就绪,oc转换成DSL语言浪费人力,而且准确率又低怎么办?怎么可以快速的用oc转换成mangofix语言呢?
这是macOS系统上的可视化辅助工具,将OC语言转成mangofix脚本。

做iOS热修复时,大量时间浪费在OC代码翻译成脚本上,提供这个辅助工具,希望能给iOSer提供便利, 本人写了一个mac应用,完美的解决了不同语法障碍,转换问题。
mac版本最低(macos10.11)支持内容:

(1)OC代码 一键 批量转换成脚本

(2)支持复制.m内容粘贴,转换

(3)支持单个OC API转换,自动补全

(4)报错提示:根据行号定位到OC代码行

自动转化文件QQ群获取。

3.打不开“OC2PatchTool.app”,因为它来自身份不明的开发者

方案1.系统偏好设置>>安全与隐私>>允许安装未知来源

方案2.打开 Terminal 终端后 ,在命令提示后输入

sudo spctl --master-disable  

OC 转换成 脚本 支持两种方式

方式1.拷贝.m文件所有内容,粘贴到OC输入框内。 示例代码:AFHTTPSessionManager.m  

image.png

方式2. 拷贝某个方法粘贴到OC输入框内,转换时会自动补全  

image.png

三.App 审核分析

其实能不能成功上线是热修复的首要前提,我们辛辛苦苦开的框架如果上不了线,那一切都是徒劳无功。下面就来分析下其审核风险。

-   首先这个是通过大量C语言混编转换的,所以苹果审核无法通过静态代码识别,这一点是没有问题的。
-   其次系统库内部也大量使用了消息转发机制。这一点可以通过符号断点验证_objc_msgForwardforwardInvocation:。所以不存在风险。此外,你还可以通过一些字符串拼接和base64编码方式进行混淆,这样就更加安全了。
-   除非苹果采用动态检验消息转发,非系统调用都不能使用,但这个成本太大了,几乎不可能。
-   Mangofix 库目前线上有大量使用,为此不用担心。就算 Mangofix 被禁用,参考 Mangofix 自己开发也不难。

综上所述:超低审核风险。

热修复框架只是为了更好的控制线上bug影响范围和给用户更好的体验。
建议:
Hotfix会在应用程序运行时动态地加载代码,因此需要进行充分的测试,以确保修复的bug或添加的新功能不会导致应用程序崩溃或出现其他问题。

有兴趣的一起来研究,QQ群:770600683

百度iOS端长连接组件建设及应用实践

作者 | 百度消息中台团队

导读

在过去的十年里,移动端技术飞速发展,移动应用逐渐成为主要的便捷访问和使用互联网的方式,承接了越来越多的业务和功能,这也意味着对移动端和服务器之间的通信效率和稳定性提出了更高的要求。为了实现更高效的实时通信和数据同步,长连接逐渐成为一种关键技术,通过维持客户端和服务器之间的持久连接,实现了双方实时数据交换,避免了频繁的建连和断连开销,从而提高用户体验、服务稳定性、可靠性等方面的表现。

本文旨在探讨长连接技术在移动端的实践,针对百度iOS端在建设长连接过程中的技术选型和整体架构逻辑将做重点展开。同时结合IM即时通讯案例的介绍和分析,展示长连接是如何在移动应用领域为类似业务场景提供解决方案的。

本文将分为五个主要部分。首先,对长连接技术进行概述,包括定义、与短连接的对比以及在移动互联网领域的常见应用。接下来,会简单介绍百度长连接服务,包括搭建的背景以及建成后提供的服务核心主流程。然后,将重点讨论百度iOS端长连接SDK搭建过程中的挑战和解决方案,包含协议的选择、DNS解析优化、长连接保活机制的设计等。紧接着,以IM即时通讯场景中的长连接实践为例,展示了长连接SDK是如何为业务实现请求数据转发、接收服务器主动推送等功能的。最后,对本文的主要内容做了总结,以及对长连接在移动端应用中未来的发展趋势和前景进行了展望。

全文7193字,预计阅读时间18分钟。

01 长连接简介

1.1 认识长连接

长连接,指在一个连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发链路检测包。

图片

1.2 长连接与短连接对比

图片

1.3 长连接在移动互联网领域的应用

长连接在移动互联网领域有广泛的应用,特别是在实现实时通信和消息推送等功能方面发挥了关键作用。例如,常见的微信、QQ这样的即时通信软件,就是通过维持客户端和服务器的长连接,实现即时传输信息的需求。又如一些网络游戏、定位服务、新闻推送等,也会使用长连接,实时推送新的动态或者消息给用户。这样,无论用户在何时何地,只要连接到互联网,就能够接收到最新的信息,极大地提升了使用者的体验度并且使得移动互联网更加便捷。

总的来说,对实时性、数据传输效率、频繁通信等有强需求的应用,长连接都是一个好的选择。

02 百度长连接服务简介

2.1 搭建统一长连接的背景

此前,百度移动端都是由各业务自运维的长连接,往往搭建和维护成本都偏高,且可复用性不大,因此计划实现一套高并发、低时延、高触达的统一长连接组件,能够更灵活高效地支持各业务接入,能够对百度系的各APP独立输出长连接服务满足各业务的诉求,从而提升服务质量,降低资源成本。

2.2 长连接服务主流程图

百度长连接服务包含客户端的长连接SDK和服务端的长连接接入层两个部分,长连接接入层又包含访问控制模块和接入模块,负责维护长连接管理及业务数据转发。下图描述了长连接建连及心跳保活过程,业务登录和登录后推送过程,以及最终长连接SDK触发断连的过程。后文将针对iOS端长连接SDK的具体实现解决方案,和长连接SDK在百度APP中的业务应用落地进行更为详细的探讨。

图片

03 百度iOS端搭建长连接SDK的解决方案

3.1 客户端搭建长连接的挑战点概述

客户端从0到1搭建一套完整的长连接SDK,这个过程涉及到多个技术点的考虑,包括但不限于:连接的创建和维护,网络协议的选择,使用加密传输、验证数据来源等方式保证长连接的安全性,通过数据传输格式选择、数据压缩等方式减少数据量提高传输效率,错误异常处理机制等,需要开发者根据实际情况进行最优实现方案的选择。在这之中,最核心的可以拆解为以下两个部分:

1、连接的创建:完整建连流程的设计,网络协议的选择,设计时需要考虑长连接建连的成功率、时延等核心指标;

2、连接的维护:保证建连成功是第一步,长连接还需要保持维护双方连接才可达成持续通信的目的,这包括:在长时间无数据交互的情况下,需要定期发送心跳包进行连接的保活,以及长连接连接断开后需要及时进行断线重连恢复连接在线状态。

3.2 核心逻辑一:连接的创建

长连接建连即客户端与服务器建立连接,是长连接SDK要做的第一件事,所有业务方数据的传输(上下行)都要基于长连接建连成功的这个前提。长连接建连并不是一个单一简单的操作,而是一个分阶段进行的过程。本小节将主要讨论在设计开发长连接建连模块之前,需要重点考虑确认的几个技术点和实现方案,以及百度iOS端长连接SDK最终实现的长连接建连完整过程的架构。

3.2.1 挑战①:协议的选择

问题点:UDP还是TCP

对于网络编程这个话题,使用哪种数据传输层协议来实现通信是一个非常基础但一直争论不休的问题。UDP和TCP各有各的应用场景,TCP能提供可靠的数据传输,UDP则有更高的传输效率,此处不再赘述TCP与UDP的区别,最终选择哪种协议实现,是一个见仁见智的问题,需结合整体应用场景、开发代价、部署和运营成本等方面综合考虑。

解决方案:TCP为主,同时小流量探索QUIC的潜力

百度iOS端长连接SDK中现有两套数据传输方案:

方案一:在长连接SDK建设初期,根据业内成熟技术方案选型的调研结果,及开发成本、维护便捷性的考虑,第一个方案是参考CocoaAsyncSocket框架改写的,基于Socket原生开发,使用TCP协议,支持TLS/SSL安全传输,并且是线程安全的,该方案较为成熟,使用便捷,建连成功率较高。目前百度APP iOS端中90%的用户流量都是走的该方案实现的长连接逻辑。

方案二:一般稳定网络传输都是通过TCP,但在网络基建本身已经越来越完善的情况下,TCP一些设计本身的问题便暴露了出来,加之TCP是在操作内核和中间固件中实现的,因此对TCP进行重大更改几乎是很难的事情,类似建连过程握手耗时长、队头阻塞等问题没有得到很好地解决,让我们开始考虑一些新的可能性。长连接SDK后续引入了基于QUIC协议实现的第二套方案。QUIC协议是建立在UDP之上,并且实现了可靠传输,相比HTTP2+TCP+TLS协议,QUIC具有不少优点:减少了TCP三次握手及TLS握手时间,改进了拥塞控制,并且没有队头阻塞的多路复用,支持连接迁移等。百度iOS长连接SDK目前通过NWConnection引入了QUIC协议的实现。QUIC的协议虽然比较先进,但这也意味着在工程实现方面有更多可优化的空间,目前方案二还处于小流量实验阶段,仍有很多优化工作有待后续进一步去落地。就当前放量所得到的数据来看,在长连接建连成功率及时延指标上,QUIC实现方案都有较好的表现。

3.2.2 挑战②:DNS解析优化

问题点:国内移动端网络所面临的DNS疑难杂症

国内各ISP运营商的LocalDNS由于域名缓存、解析转发、LocalDNS递归出口NAT的原因,容易引起DNS被劫持造成服务不可用、DNS调度不准确导致性能退化等问题。DNS解析的效率和准确性,直接影响长连接建连的质量,进而影响公司的业务。

解决方案:HTTPDNS

因此在百度iOS端长连接SDK中,采用当前业界比较主流的解决方案:HTTPDNS,来替代LocalDNS解析。HTTPDNS是利用HTTP协议与DNS服务器进行交互,绕开了运营商的LocalDNS服务,有效防止了域名劫持,提高域名解析效率。

3.2.3 完整解决方案:百度iOS端长连接建连整体流程

建连时机

在百度APP中,统一维护了一系列系统事件和生命周期供各个组件监听。iOS长连接SDK根据百度APP的业务特性,选择在环境搭建完成事件后触发长连接建连,即等待APP启动必要数据比如首页资源等加载完成后,开始触发长连接建连。

建连完整过程

下图展示了长连接SDK建连的四个过程:

获取Token

  • 获取Token的意义:狭义上Token指的是长连接访问控制模块返回的access-token,后续随长连接登录请求上行到长连接接入层,由接入层向长连接访问控制模块进行鉴权用。广义上随此次Token请求下发的,还有传输协议及访问点等数据,包括但不限于:长连接协议使用QUIC还是TCP,是否优先ipv6,连接域名和端口,日志打点小流量开关等。

  • 获取Token的机制:获取Token优先走本地缓存,当本地缓存无有效数据时,才发出网络请求,该请求是基于NSURLSession实现的短连接请求;Token在服务端和客户端均有缓存,存在过期时间,若Token过期,会在下图中阶段四通过长连接登录请求失败体现,这时会清空本地Token缓存并触发重新建连。

DNS域名解析:如前所述,长连接SDK中使用HTTPDNS替代LocalDNS来防止DNS劫持、提高解析效率。同时在iOS Release环境下,为提高DNS解析效率,本地建立了缓存机制,HTTPDNS解析结果返回后会更新本地缓存,下次建连过程优先取缓存,缓存不合法才走网络请求。

建立Socket连接:Socket建连过程涉及到传输协议的选择,根据前面介绍,iOS长连接SDK目前是通过小流量实验的方式,10%的用户走QUIC建连,90%的用户走TCP建连。

长连接登录请求:携带Token中获取的access-token上行请求完成鉴权,长连接登录请求返回成功意味着整个建连过程完成,业务层可开始正常使用长连接进行通讯,若登录返回报错,则会触发重连。

顺利完成这四个阶段后,长连接会在该链路上持续发送心跳包进行连接保活,在异常断连或压后台等触发的主动断连之前,一直保持连接在线状态,为各个业务的数据传输提供通路。

图片

3.3 核心逻辑二:连接的维护

3.3.1 维护连接的意义

上个小节介绍了长连接连接建立的全过程,长连接建连成功后,实际已处于可用状态,即各业务基于长连接的通信已经可以正常进行。但在此之后,保持长连接的可用性也是非常重要的。如果长连接无法很好地保持,在连接已经失效的情况下服务端继续推送下行通知而端却收不到,造成资源的浪费,同时无法及时重新建连,对业务造成损失。

3.3.2 维护长连接的解决方案

针对可能导致长连接断开的几种主要原因,长连接SDK建立了对应的机制来保证连接的稳定性,可总结为两点:心跳保活和断线重连。

图片

解决方案①:心跳保活

心跳保活的定义:实现长连接保活的方式通常是采用应用层心跳,通过心跳包的超时或报错等来执行重连操作。心跳一般是指某端(通常是客户端)每隔一定时间向另一端(通常是服务端)发送自定义指令,以判断双方是否存活,因其按照一定间隔发送,类似于心跳,故被称为心跳保活。

百度iOS端长连接SDK心跳保活机制:长连接登陆请求成功后,解析返回数据,若服务端下发了心跳包的间隔时间,则以服务端下发的时间间隔持续发送心跳包进行连接保活,若没有下发心跳包间隔时间,客户端会默认60s间隔时间来触发心跳包的发送。具体心跳保活过程见下图。

图片

解决方案②:断线重连

断线重连原理:在长连接可能被断开的场景(压后台重进APP、网络状态变更等),检测长连接的可用状态,监测到连接不可用时,及时触发重连机制。

百度iOS端长连接SDK断线重连机制:具体触发断线重连的时机见下图,iOS长连接SDK内部维护有串行队列和统一的长连接状态监测记录,不会导致重复建连的发生。

图片

04 长连接在百度APP中的应用与实践

4.1 长连接在百度APP中的业务落地

长连接是客户端到服务端的一种全双工连接,建连完成后,可以为业务方提供请求转发、服务端主动推送等服务。在百度APP中,包括在线健康诊疗、高考志愿填报咨询、情感心理辅导等一系列实时咨询服务,发送直播弹幕、加入某大V粉丝群聊天、私信好友等多种用户实时沟通场景的落地,以及实现用户在线情况下云端可及时主动下发配置控制端的基础能力建设,都离不开长连接的支持。长连接为各个业务与自己服务端的数据交互提供了稳定便捷的方式和渠道。

下图为百度APP中长连接与落地业务的结构示意图。完整的长连接模块包括了客户端的长连接SDK和服务端的长连接接入层两个部分,作为各个业务与自己服务端数据交流的中间渠道,处理了包括连接建立与保活、实现各业务客户端与自己服务端的数据双向互发等逻辑。下面将重点关注长连接在IMSDK实时聊天通讯场景中的实践。

图片

4.2 长连接在IM即时通讯场景中的实践

4.2.1 背景介绍

IMSDK,即百度消息中台为百度APP及百度系其他产品打造的具备应用内即时通讯能力的客户端SDK,包括多种用户沟通场景:私聊、群聊、聊天室、直播弹幕等,并帮助业务推送消息通知触达用户,建立B端和C端的沟通渠道。目前主功能诸如拉取会话列表、拉取消息、发送消息、消息已读等均为长连接实现。本小节将通过介绍用户发送消息、用户收到新消息通知这两个IM及时通讯中的常见场景,展示长连接提供的数据转发和服务器主动推送能力是如何在业务场景落地的。

4.2.2 实践1:实时聊天场景下用户发送消息

实践场景

实时聊天场景下,用户在聊天框向自己好友发送一条消息,消息如果发送失败了,应用通常会在本条消息气泡旁展示一个红叹号,这个应用场景对于互联网用户应该都非常熟悉。从技术角度看,本质上是业务客户端向自己的服务器上行一个请求,服务器再将请求结果返回给客户端。这是一个典型的需要频繁点到点通信的场景,非常适合基于长连接来实现。长连接SDK对外提供了封装好的长连接请求类,外部业务方诸如IMSDK在上行长连接请求时通过创建该类的实例,将上行所需参数和数据赋值给请求实例,并设置回调闭包用于接收和处理请求回执数据和结果,最后将请求发出。业务不需要考虑数据传输及转发等逻辑,长连接会充当业务客户端和服务器之间的通路,黑盒处理这个过程。

技术难点

对于长连接SDK而言,在这条通路上最重要也是比较复杂的逻辑点在于,各个业务方的上行请求和下行通知都是并发进行的,长连接SDK如何有序地管理数据流向。上行请求即写流,接收下行数据即读流,下面就读写流的管理,与请求同回执数据的匹配问题的解决方案作简要的介绍。

技术实现

长连接SDK内就读写数据维护有两个队列:读队列和写队列,以及维护了一个缓存池用作请求实例和请求回执数据的匹配。业务方上行一个长连接请求,实际上是将请求任务添加到写队列中,如果此时处于可写流状态,还会触发写流。当socket建连成功以后,会取出写队列队头的任务,开始写流,写流完毕会检查写队列是否为空,不为空继续取队头任务执行,直至写队列为空为止。同时socket建连成功还会添加一次读任务到读队列中,并检查如果此时处于可读状态,便取出队头第一个读任务,开始读流,读流成功后会继续添加一个读任务到读队列,循环读流操作。

读流得到的服务端下行返回数据,通过serviceId(业务编号)+ methodId(长连接请求方法编号)+ 请求发起的时间戳组成唯一键值,去缓存区匹配到下行返回数据对应的请求体,通过回调的方式,将请求结果返回到调用方。该请求一旦被回调过一次,其实例将从缓存区被删除,及时释放缓存区内存,并且保证一次请求不会发生多次回调的情况。

图片

4.2.3 实践2:实时聊天场景下用户收到新消息通知

实践场景

实时聊天场景下,用户是如何收到别的用户发送给他的新消息通知的呢?其实是依靠服务器的下行通知到客户端。长连接不仅提供为业务客户端转发上行请求的能力,还提供了服务端主动推送的服务。比如在IM业务中,依靠IM服务器下行新消息通知,来完成消息的实时接收和拉取。这些通知又是如何到达IMSDK的呢?其实它与上一小节IMSDK上行长连接请求的过程类似。

技术实现

在IMSDK的长连接管理类初始化阶段,会对需要接收的下行通知方法进行注册,这里的注册实际上指的就是上行多个长连接请求,每个请求有对应的serviceID(业务编号)和methodID(需要注册的通知方法号码)。跟上一小节长连接请求不同的点在于,这些请求在收到回执数据后不会从长连接SDK请求缓存区里移除,而是会长期存在,只要读流时读到了对应methodID的数据,就能在请求缓存区找到对应请求,将下行数据传到IMSDK了。这样一来,只要长连接在线,业务方就能实时接收到其服务器下行的通知消息了。

05 结语

长连接服务的核心大致可分为:建连过程、连接维持过程以及数据传输过程。本文给出了搭建长连接服务过程中面临的一些挑战和解决方案,并结合长连接功能在百度APP即时通讯场景下的实践,简要介绍了百度iOS端长连接SDK的整体架构。

在移动端,长连接技术的应用前景非常广阔。随着5G和6G等高速移动网络的发展,将使得移动应用程序能够更加高效地使用长连接技术,从而实现更加实时和高效的数据交换。这也为对实时数据交换有强需求的应用场景提供了更广阔的想象空间,诸如物联网、智能家居、虚拟现实和增强现实等技术,长连接都将在其中发挥更加重要的作用。

——END——

推荐阅读:

百度App启动性能优化实践篇

扫光动效在移动端应用实践

Android SDK安全加固问题与分析

搜索语义模型的大规模量化实践

如何设计一个高效的分布式日志服务平台

视频与图片检索中的多模态语义匹配模型:原理、启示、应用与展望

从预编译的角度理解 Swift 与 Objective-C 及混编机制

这篇文章是我早年在美团技术博客上发布的一篇文章, 部分内容可能已经过时, 请开发者注意.
将文章同步到个人博客主要是为了同步和备份.

TL;DR

文章涉及面广,篇幅长,阅读完需要耗费一定的时间与精力,如果你带有较为明确的阅读目的,可以参考以下建议完成阅读:

  • 如果你对预编译的理论知识已经了解,可以直接从【原来它是这样的】的章节开始进行阅读,这会让你对预编译有一个更直观的了解。
  • 如果你对 search path 的工作机制感兴趣,可以直接【关于第一个问题】的章节阅读,这会让你更深刻,更全面的了解到它们的运作机制,
  • 如果您对 Xcode Phases 里的 Header 的设置感到迷惑,可以直接从【揭开 Public,Private,Project 的真实面目】阅读,这会让你理解为什么说 Private 并不是真正的私有头文件
  • 如果你想了解如何通过 hmap 技术提升编译速度,可以从关于【基于 hmap 优化 Search Path 的策略】的章节开始阅读,这会给你提供一种新的编译加速思路。
  • 如果你想了解如何通过 VFS 技术进行 Swift 产物的构建,可以从 【关于第二个问题】开始阅读,这会让你理解如何用另外一种提升构建 Swift 产物的效率。
  • 如果你想了解 Swift 和 Objective-C 是如何找寻方法声明的,可以从 【Swift 来了】的章节阅读,这会让你从原理上理解混编的核心思路和解决方案。

概述

随着 Swift 的发展,国内的技术社区出现了一些关于如何实现 Swift 与 Objective-C 混编的文章,这些文章的主要内容还是围绕着指导开发者进行各种操作来实现混编的效果,例如在 Build Setting 中开启某个选项,在 podspec 中增加某个字段,鲜有文章对这些操作背后的工作机制做剖析,大部分核心概念也都是一笔带过。

正是因为这种现状,很多开发者在面对与预期不符的行为时,又或者各种奇怪报错时,会无从下手,而这也是由于对其工作原理不够了解所导致的。

笔者自身在美团平台负责 CI/CD 相关的工作,这其中也包含了 Objective-C 与 Swift 混编的内容,出于让更多开发者能够进一步理解混编工作机制的目的,笔者编写了这篇技术文章。

该文章从预编译的基础知识入手,由浅至深的介绍了 Objective-C 和 Swift 的工作机制,并通过这些机制来解释混编项目中使用到的技术和各种参数的作用,由此来指导开发者如何进行混编。

好了废话不多说,我们开始吧!

预编译知识指北

#import 的机制和缺点

在我们使用某些系统组件的时候,我们通常会写出如下形式的代码:

1
#import <UIKit/UIKit.h>

#import 其实是 #include 语法的微小创新,它们在本质上还是十分接近的。#include 做的事情其实就是简单的复制粘贴,将目标 .h 文件中的内容一字不落地拷贝到当前文件中,并替换掉这句 #include,而 #import 实质上做的事情和 #include 是一样的,只不过它还多了一个能够避免头文件重复引用的能力而已。

为了更好的理解后面的内容,我们这里需要展开说一下它到底是如何运行的?

从最直观的角度来看:

假设在 MyApp.m 文件中,我们 #importiAd.h 文件,编译器解析此文件后,开始寻找 iAd 包含的内容(ADInterstitialAd.hADBannerView.h),及这些内容包含的子内容(UIKit.hUIController.hUIView.hUIResponder.h),并依次递归下去,最后,你会发现 #import <iAd/iAd.h> 这段代码变成了对不同 SDK 的头文件依赖。

01.png

如果你觉得听起来有点费劲,或者似懂非懂,我们这里可以举一个更加详细的例子,不过请记住,对于 C 语言的预处理器而言, #import 就是一种特殊的复制粘贴。

结合前面提到的内容,在 AppDelegate 中添加 iAd.h

1
2
3
4
#import <iAd/iAd.h>
@implementation AppDelegate
//...
@end

然后编译器会开始查找 iAd/iAd.h 到底是哪个文件且包含何种内容,假设它的内容如下:

1
2
3
4
/* iAd/iAd.h */
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

在找到上面的内容后,编译器将其复制粘贴到 AppDelegate 中:

1
2
3
4
5
6
7
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

现在,编译器发现文件里有 3 个 #import 语句 了,那么就需要继续寻找这些文件及其相应的内容,假设 ADBannerView.h 的内容如下:

1
2
3
4
5
6
7
8
/* iAd/ADBannerView.h */
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

那么编译器会继续将其内容复制粘贴到 AppDelegate 中,最终变成如下的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

这样的操作会一直持续到整个文件中所有 #import 指向的内容被替换掉,这也意味着 .m 文件最终将变得极其的冗长。

虽然这种机制看起来是可行的,但它有两个比较明显的问题:健壮性和拓展性。

健壮性

首先这种编译模型会导致代码的健壮性变差!

这里我们继续采用之前的例子,在 AppDelegate 中定义 readonly0x01,而且这个定义的声明在 #import 语句之前,那么此时又会发生什么事情呢?

编译器同样会进行刚才的那些复制粘贴操作,但可怕的是,你会发现那些在属性声明中的 readonly 也变成了 0x01,而这会触发编译器报错!

1
2
3
4
5
6
7
8
9
10
11
@interface ADBannerView : UIView
@property (nonatomic, 0x01) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

@implementation AppDelegate
//...
@end

面对这种错误,你可能会说它是开发者自己的问题。

确实,通常我们都会在声明宏的时候带上固定的前缀来进行区分。但生活里总是有一些意外,不是么?

假设某个人没有遵守这种规则,那么在不同的引入顺序下,你可能会得到不同的结果,对于这种错误的排查,还是挺闹心的,不过这还不是最闹心的,因为还有动态宏的存在,心塞 ing。

所以这种靠遵守约定来规避问题的解决方案,并不能从根本上解决问题,这也从侧面反应了编译模型的健壮性是相对较差的。

拓展性

说完了健壮性的问题,我们来看看拓展性的问题。

Apple 公司对它们的 Mail App 做过一个分析,下图是 Mail 这个项目里所有 .m 文件的排序,横轴是文件编号排序,纵轴是文件大小。

IMAGE

可以看到这些由业务代码构成的文件大小的分布区间很广泛,最小可能有几 kb,最大的能有 200+ kb,但总的来说,可能 90% 的代码都在 50kb 这个数量级之下,甚至更少。

如果我们往该项目的某个核心文件(核心文件是指其他文件可能都需要依赖的文件)里添加了一个对 iAd.h 文件的引用,对其他文件意味着什么呢?

这里的核心文件是指其他文件可能都需要依赖的文件

这意味着其他文件也会把 iAd.h 里包含的东西纳入进来,当然,好消息是,iAd 这个 SDK 自身只有 25KB 左右的大小。

IMAGE

但你得知道 iAd 还会依赖 UIKit 这样的组件,这可是个 400KB+ 的大家伙

IMAGE

所以,怎么说呢?

在 Mail App 里的所有代码都需要先涵盖这将近 425KB 的头文件内容,即使你的代码只有一行 hello world

如果你认为这已经让人很沮丧的话,那还有更打击你的消息,因为 UIKit 相比于 macOS 上的 Cocoa 系列大礼包,真的小太多了,Cocoa 系列大礼包可是 UIKit 的 29 倍……

所以如果将这个数据放到上面的图表中,你会发现真正的业务代码在 file size 轴上的比重真的太微不足道了。

所以这就是拓展性差带来的问题之一!

很明显,我们不可能用这样的方式引入代码,假设你有 M 个源文件且每个文件会引入 N 个头文件,按照刚才的解释,编译它们的时间就会是 M * N,这是非常可怕的!

备注:文章里提到的 iAd 组件为 25KB,UIKit 组件约为 400KB, macOS 的 Cocoa 组件是 UIKit 的 29 倍等数据,是 WWDC 2013 Session 404 Advances in Objective-C 里公布的数据,随着功能的不断迭代,以现在的眼光来看,这些数据可能已经偏小,在 WWDC 2018 Session 415 Behind the Scenes of the Xcode Build Process 中提到了 Foundation 组件,它包含的头文件数量大于 800 个,大小已经超过 9MB。

PCH(PreCompiled Header)是一把双刃剑

为了优化前面提到的问题,一种折中的技术方案诞生了,它就是 PreCompiled Header。

我们经常可以看到某些组件的头文件会频繁的出现,例如 UIKit,而这很容易让人联想到一个优化点,我们是不是可以通过某种手段,避免重复编译相同的内容呢?

而这就是 PCH 为预编译流程带来的改进点!

它的大体原理就是,在我们编译任意 .m 文件前, 编译器会先对 PCH 里的内容进行预编译,将其变为一种二进制的中间格式缓存起来,便于后续的使用。当开始编译 .m 文件时,如果需要 PCH 里已经编译过的内容,直接读取即可,无须再次编译。

虽然这种技术有一定的优势,但实际应用起来,还存在不少的问题。

首先,它的维护是有一定的成本的,对于大部分历史包袱沉重的组件来说,将项目中的引用关系梳理清楚就十分麻烦,而要在此基础上梳理出合理的 PCH 内容就更加麻烦,同时随着版本的不断迭代,哪些头文件需要移出 PCH,哪些头文件需要移进 PCH 将会变得越来越麻烦。

其次,PCH 会引发命名空间被污染的问题,因为 PCH 引入的头文件会出现在你代码中的每一处,而这可能会是多于的操作,比如 iAd 应当出现在一些与广告相关的代码中,它完全没必要出现在帮助相关的代码中(也就是与广告无关的逻辑),可是当你把它放到 PCH 中,就意味组件里的所有地方都会引入 iAd 的代码,包括帮助页面,这可能并不是我们想要的结果!

如果你想更深入的了解 PCH 的黑暗面,建议阅读 4 Ways Precompiled Headers Cripple Your Code ,里面已经说得相当全面和透彻。

所以 PCH 并不是一个完美的解决方案,它能在某些场景下提升编译速度,但也有缺陷!

Clang Module 的来临

为了解决前面提到的问题,Clang 提出了 module 的概念,关于它的介绍可以在 Clang 官网 上找到。

简单来说,你可以把它理解为一种对组件的描述,包含了对接口(API)和实现(dylib/a)的描述,同时 module 的产物是被独立编译出来的,不同的 module 之间是不会影响的。

在实际编译之时,编译器会创建一个全新的空间,用它来存放已经编译过的 module 产物。如果在编译的文件中引用到某个 module 的话,系统将优先在这个列表内查找是否存在对应的中间产物,如果能找到,则说明该文件已经被编译过,则直接使用该中间产物,如果没找到,则把引用到的头文件进行编译,并将产物添加到相应的空间中以备重复使用。

在这种编译模型下,被引用到的 module 只会被编译一次,且在运行过程中不会相互影响,这从根本上解决了健壮性和拓展性的问题。

module 的使用并不麻烦,同样是引用 iAd 这个组件,你只需要这样写即可。

1
@import iAd;

在使用层面上,这将等价于以前的 #import <iAd/iAd.h> 语句,但是会使用 clang module 的特性加载整个 iAd 组件。如果只想引入特定文件(比如 ADBannerView.h),原先的写法是 #import <iAd/ADBannerView.h.h>,现在可以写成:

1
@import iAd.ADBannerView;

通过这种写法会将 iAd 这个组件的 API 导入到我们的应用中,同时这种写法也更符合语义化(semanitc import)。

虽然这种引入方式和之前的写法区别不大,但它们在本质上还是有很大程度的不同,Module 不会“复制粘贴”头文件里的内容,也不会让 @import 所暴露的 API 被开发者本地的上下文篡改,例如前面提到的 #define readonly 0x01

此时,如果你觉得前面关于 clang module 的描述还是太抽象,我们可以再进一步去探究它工作原理, 而这就会引入一个新的概念 – modulemap。

不论怎样,module 只是一个对组件的抽象描述罢了,而 modulemap 则是这个描述的具体呈现,它对框架内的所有文件进行了结构化的描述,下面是 UIKit 的 modulemap 文件

1
2
3
4
5
framework module UIKit {
umbrella header "UIKit.h"
module * {export *}
link framework "UIKit"
}

这个 module 定义了组件的 umbrella header 文件(UIKit.h),需要导出的子 module(所有),以及需要 link 的框架名称(UIKit),正是通过这个文件,让编译器了解到 Module 的逻辑结构与头文件结构的关联方式!

可能又有人会好奇,为什么我从来没看到过 @import 的写法呢?

这是因为 Xcode 的编译器能够将符合某种格式的 #import 语句自动转换成 module 识别的 @import 语句,从而避免了开发者的手动修改。

画板.png

唯一需要开发者完成的就是开启相关的编译选项。

IMAGE

对于上面的编译选项,需要开发者注意的是:

Apple Clang - Language - ModulesEnable Module 选项是指引用系统库的的时候,是否采用 module 的形式。

Packaing 里的 Defines Module 是指开发者编写的组件是否采用 module 的形式。

说了这么多,我想你应该对 #importpch@import 有了一定的概念。当然,如果我们深究下去,可能还会有如下的疑问:

  • 对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?
  • 对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

为了解答这些问题,我们不妨先动手实践一下,看看上面的理论知识在现实中的样子。

原来它是这样的

在前面的章节中,我们将重点放在了原理上的介绍,而在在这个章节中,我们将动手看看这些预编译环节的实际样子。

#import 的样子

假设我们的源码样式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import "SQViewController.h"
#import <SQPod/ClassA.h>

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

想要查看代码预编译后的样子,我们可以在 Navigate to Related Items 按钮中找到 Preprocess 选项

IMAGE

既然知道了如何查看预编译后的样子,我们不妨看看代码在使用 #import, PCH 和 @import 后,到底会变成什么样子?

这里我们假设被引入的头文件,即 ClassA 中的内如如下:

1
2
3
4
@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

通过 preprocess 可以看到代码大致如下,这里为了方便展示,将无用代码进行了删除。这里记得要将 Build Setting 中 Packaging 的 Define Module 设置为 NO,因为其默认值为 YES,而这会导致我们开启 clang module 特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@import UIKit;
@interface SQViewController : UIViewController
@end

@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

这么一看,#import 的作用还就真的是个 copy & write。

pch 的真容

对于 CocoaPods 默认创建的组件,一般都会关闭 PCH 的相关功能,例如笔者创建的 SQPod 组件,它的 Precompile Prefix Header 功能默认值为 NO。

IMAGE

为了查看预编译的效果,我们将 Precompile Prefix Header 的值改为 YES,并编译整个项目,通过查看 build log,我们可以发现相比于 NO 的状态,在编译的过程中,增加了一个步骤,即 Precompile SQPod-Prefix.pch 的步骤。

画板.png

通过查看这个命令的 -o 参数,我们可以知道其产物是名为 SQPod-Prefix.pch.gch 的文件

IMAGE

这个文件就是 PCH 预编译后的产物,同时在编译真正的代码时,会通过 -include 参数将其引入

画板.png

又见 clang module

在开启 Define Module 后,系统会为我们自动创建相应的 modulemap 文件,这一点可以在 Build Log 中查找到

IMAGE

它的内容如下:

1
2
3
4
5
6
framework module SQPod {
umbrella header "SQPod-umbrella.h"

export *
module * { export * }
}

当然,如果系统自动生成的 modulemap 并不能满足你的诉求,我们也可以使用自己创建的文件,此时只需要在 Build Setting 的 Module Map File 选项中填写好文件路径,相应的 clang 命令参数是 -fmodule-map-file

画板.png

最后让我们看看 module 编译后的产物形态。

这里我们构建一个名为 SQPod 的 module ,将它提供给名为 Example 的工程使用,通过查看 -fmodule-cache-path 的参数,我们可以找到 module 的缓存路径

画板.png

进入对应的路径后,我们可以看到如下的文件

IMAGE

其中后缀名为 pcm 的文件就是构建出来的二进制中间产物。

现在,我们不仅知道了预编译的基础理论知识,也动手查看了预编译环节在真实环境下的产物,现在我们要开始解答之前提到的两个问题了!

打破砂锅问到底

关于第一个问题

对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?

在早期的 clang 编译过程中,头文件的查找机制还是基于 header search path 的,这也是大多数人所熟知的工作机制,所以我们不做赘述,只做一个简单的回顾。

header seach path 是构建系统提供给编译器的一个重要参数,它的作用是在编译代码的时候,为编译器提供了查找相应头文件路径的信息,通过查阅 Xcode 的 Build System 信息,我们可以知道相关的设置有三处 header search path,system header search path,user header search path。

IMAGE

它们的区别也很简单,system header search path 是针对系统头文件的设置,通常代指 <> 方式引入的文件,user header search path 则是针对非系统头文件的设置,通常代指 "" 方式引入的文件,而 header search path 并不会有任何限制,它普适于任何方式的头文件引用。

听起来好像很复杂,但关于引入的方式,无非是以下四种形式:

1
2
3
4
#import <A/A.h>
#import "A/A.h"
#import <A.h>
#import "A.h"

我们可以两个维度去理解这个问题,一个是引入的符号形式,另一个是引入的内容形式

  • 引入的符号形式:通常来说,双引号的引入方式(“A.h” 或者 "A/A.h")是用于查找本地的头文件,需要指定相对路径,尖括号的引入方式(<A.h> 或者 <A/A.h>)是全局的引用,其路径由编译器提供,如引用系统的库,但随着 header search path 的加入,让这种区别已经被淡化了。

  • 引入的内容形式:对于 X/X.hX.h 这两种引入的内容形式,前者是说在对应的 search path 中,找到目录 A 并在 A 目录下查找 A.h,而后者是说在 search path 下查找 A.h 文件,而不一定局限在 A 目录中,至于是否递归的寻找则取决于对目录的选项是否开启了 recursive 模式

画板.png

在很多工程中,尤其是基于 CocoaPods 开发的项目,我们已经不会区分 system header search path 和 user header search path,而是一股脑的将所有头文件路径添加到 header search path 中,这就导致我们在引用某个头文件时,不会再局限于前面提到的约定,甚至在某些情况下,前面提到的四种方式都可以做到引入某个指定头文件。

header maps

随着项目的迭代和发展,原有的头文件索引机制还是受到了一些挑战,为此,Clang 官方也提出了自己的解决方案。

为了理解这个东西,我们首先要在 build setting 中开启 Use Header Map 选项。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c SQViewController.m -o SQViewcontroller.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

画板.png

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件。

那 hmap 到底这是个什么东西呢?

当我们开启 Build Setting 中的 Use Header Map 选项后,会自动生成的一份头文件名和头文件路径的映射表,而这个映射表就是 hmap 文件,不过它是一种二进制格式的文件,也有人叫它为 header map,总之,它的核心功能就是让编译器能够找到相应头文件的位置。

为了更好的理解它,我们可以通过 milend 编写的小工具 hmap 来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下:

IMAGE

需要注意,映射表的键值并不是简单的文件名和绝对路径,它的内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式,还是 <...> 的形式,又或是在 Build Phase 里 Header 的配置情况。

IMAGE

至此我想你应该明白了,一旦开启 Use Header Map 选项后,Xcode 会优先去 hmap 映射表里寻找头文件的路径,只有在找不到的情况下,才会去 header search path 中提供的路径遍历搜索。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

查找系统库的头文件

上面的过程让我们理解了在 header map 技术下,编译器是如何寻找相应的头文件的,那针对系统库的文件又是如何索引的呢?例如 #import <Foundation/Foundation.h>

回想一下上一节 console 的输出内容,它的形式大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

我们会发现,这些路径大部分是用于查找非系统库文件的,也就是开发者自己引入的头文件,而与系统库相关的路径只有以下两个:

1
2
3
#include <...> search starts here:
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks.(framework directory)

当我们查找 Foundation/Foundation.h 这个文件的时候,我们会首先判断是否存在 Foundation 这个 framework。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework

接着,我们会进入 framework 的 Headers 文件夹里寻找对应的头文件

1
$SDKROOT/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h

如果没有找到对应的文件,索引过程会在此中断,并结束查找。

以上便是系统库的头文件搜索逻辑。

framework search path

到底为止,我们已经解释了如何依赖 header search path,hmap 等技术寻找头文件的工作机制,也介绍了寻找系统库(system framework)头文件的工作机制。

那这是全部头文件的搜索机制么?答案是否定的,其实我们还有一种头文件搜索机制,它是基于 Framework 这种文件结构进行的。

IMAGE

对于开发者自己的 Framework,可能会存在 “private” 头文件,例如在 podspec 里用 private_header_files 的描述文件,这些文件在构建的时候,会被放在 Framework 文件结构中的 PrivateHeaders 目录。

所以针对有 PrivateHeaders 目录的 Framework 而言,clang 在检查 Headers 目录后,会去 PrivateHeaders 目录中寻找是否存在匹配的头文件,如果这两个目录都没有,才会结束查找。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework/PrivateHeaders/SecretClass.h

不过也正是因为这个工作机制,会产生一个特别有意思的问题,那就是当我们使用 Framework 的方式引入某个带有 “private” 头文件的组件时,我们总是可以以下面的方式引入这个头文件!

画板.png

怎么样,是不是很神奇,这个被描述为 “private” 的头文件怎么就不私有了?

究其原因,还是由于 clang 的工作机制,那为什么 clang 要设计出来这种看似很奇怪的工作机制呢?

揭开 Public,Private,Project 的真实面目

其实你也看到我在上一段的写作中,将所有 private 单词标上了双引号,其实就是在暗示,我们曲解了 private 的含义。

那么这个 “private” 到底是什么意思呢?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,且分别放在最终产物的 Headers 和 PrivateHeaders 目录中,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

那么 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

IMAGE

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实这么看来,CocoaPods 对于 public 和 private 的理解是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 ready 的头文件,更像一个 In Progress 的含义。

所以,如果你真的不想对外暴露某些头文件,请不要再使用 Headers 里的 Private 或者 podspec 里的 private_header_files 了。

至此,我想你应该彻底理解了 Search Path 的搜索机制和略显奇怪的 Public,Private,Project 设定了!

基于 hmap 优化 Search Path 的策略

在查找系统库的头文件的章节中,我们通过 -v 参数看到了寻找头文件的搜索顺序:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

假设,我们没有开启 hmap 的话,所有的搜索都会依赖 header search path 或者 framework search path,那这就会出现 3 种问题:

  • 第一个问题,在一些巨型项目中,假设依赖的组件有 400+,那此时的索引路径就会达到 800+ 个(一份 public 路径,一份 private 路径),同时搜索操作可以看做是一种 IO 操作,而我们知道 IO 操作通常也是一种耗时操作,那么,这种大量的耗时操作必然会导致编译耗时增加。
  • 第二个问题,在打包的过程中,如果 header search path 过多过长,会触发命令行过长的错误,进而导致命令执行失败的情况。
  • 第三个问题,在引入系统库的头文件时,clang 会将前面提到的目录遍历完才进入搜索系统库的路径,也就是 $(SDKROOT)/System/Library/Frameworks(framework directory),即前面的 header search 路径越多,耗时也会越长,这是相当不划算的。

那如果我们开启 hmap 后,是否就能解决掉所有的问题呢?

实际上并不能,而且在基于 CocoaPods 管理项目的状况下,又会带来新的问题。下面是一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。

其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

画板.png

此时我们将 PodA 里的文件全部放在 Header 的 Project 类型中。

IMAGE

在基于 Framework 的搜索机制下,我们是无法以任何方式引入到 ClassB 的,因为它既不在 Headers 目录,也不在 PrivateHeader 目录中。

可是如果我们开启了 Use Header Map 后,由于 PodA 和 PodB 都在 Pods 这个 Project 下,满足了 Header 的 Project 定义,通过 Xcode 自动生成的 hmap 文件会带上这个路径,所以我们还可以在 PodB 中以 #import "ClassB.h" 的方式引入。

而这种行为,我想应该是大多数人并不想要的结果,所以一旦开启了 Use Header Map,再结合 CocoaPods 管理工程项目的模式,我们极有可能会产生一些误用私有头文件的情况,而这个问题的本质是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

除此之外,CocoaPods 在处理头文件的问题上还有一些让人迷惑的地方,它在创建头文件产物这块的逻辑大致如下:

  • 在构建产物为 Framework 的情况下:
    • 根据 podspec 里的 public_header_files 字段的内容,将相应头文件设置为 Public 类型,并放在 Headers 中
    • 根据 podspec 里的 private_header_files 字段的内容,将相应文件设置为 Private 类型,并放在 PrivateHeader 中
    • 将其余未描述的头文件设置为 Project 类型,且不放入最终的产物中
    • 如果 podspec 里未标注 public 和 private 的时候,会将所有文件设置为 public 类型,并放在 Header 中
  • 在构建产物为 Static Library 的情况下:
    • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
    • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
    • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
    • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,还导致了另外一种有意思的问题。

在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "A.h" 的键值引用,也就是说只有 #import "A.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径。

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 并不会提升编译速度,而这同样是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

画板.png

这样来看的话,虽然 hmap 有种种优势,但是在 CocoaPods 的世界里显得格格不入,也无法发挥自身的优势。

那这就真的没有办法解决了么?

当然,问题是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件。

举一个简单的例子,通过遍历 PODS 目录里的内容去构建索引表内容,借助 hmap 工具生成 header map 文件,然后将 Cocoapods 在 header search path 中生成的路径删除,只添加一条指向我们自己生成的 hmap 文件路径,最后关闭 Xcode 的 Ues Header Map 功能,也就是 Xcode 自动生成 hmap 的功能,如此这般,我们就实现了一个简单的,基于 CocoaPods 的 header map 功能。

同时在这个基础上,我们还可以借助这个功能实现不少管控手段,例如

  • 从根本上杜绝私有文件被暴露的可能性。
  • 统一头文件的引用形式

目前我们已经自研了一套基于上述原理的 cocoapods 插件,它的名字叫做 cocoapods-hmap-prebuilt,是由笔者与同事 @宋旭陶 共同开发的,

说了这么多,让我们看看它在实际工程中的使用效果!

经过全源码编译的测试,我们可以看到该技术在提速上的收益较为明显,以美团和点评 App 为例,全链路时长能够提升 45% 以上,其中 Xcode 打包时间能提升 50%。

关于第二个问题

对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

首先,我们来明确一个问题, clang 是如何决定编译当下组件的 module 呢

#import <Foundation/NSString.h> 为例,当我们遇到这个头文件的时候:

首先会去 Framework 的 Headers 目录下寻找相应的头文件是否存在,然后就会到 Modules 目录下查找 modulemap 文件

画板.png

此时,Clang 会去查阅 modulemap 里的内容,看看 NSString 是否为 Foundation 这个 Module 里的一部分,

1
2
3
4
5
6
7
8
9
10
11
12
13
// Module Map - Foundation.framework/Modules/module.modulemap
framework module Foundation [extern_c] [system] {
umbrella header "Foundation.h"
export *
module * {
export *
}

explicit module NSDebug {
header "NSDebug.h"
export *
}
}

很显然,这里通过 umbrella header,我们是可以在 Foundation.h 中找到 NSString.h 的。

1
2
3
4
5
6
// Foundation.h

#import <Foundation/NSStream.h>
#import <Foundation/NSString.h>
#import <Foundation/NSTextCheckingResult.h>

至此,clang 会判定 NSString.h 是 Foundation 这个 module 的一部分并进行相应的编译工作,此时也就意味着 #import <Foundation/NSString.h> 会从之前的 textual import 变为 module import

Module 的构建细节

上面的内容解决了是否构建 module,而这一块我们会详细阐述构建 module 的过程!

在构建开始前,clang 会创建一个完全独立的空间来构建 module,在这个空间里会包含 module 涉及的所有文件,除此之外不会带入其他任何文件的信息,而这也是 module 健壮性好的关键因素之一。

不过,这并不意味着我们无法影响到 module 的唯一性,真正能影响到其唯一性的是其构建的参数,也就是 clang 命令后面的内容,关于这一点后面还会继续展开,这里我们先点到为止。

当我们在构建 Foundation 的时候,我们会发现 Foundation 自身要依赖一些组件,这意味着我们也需要构建被依赖组件的 module

画板.png

但很明显的是,我们会发现这些被依赖组件也有自己的依赖关系,在它们的这些依赖关系中,极有可能会存在重复的引用。

画板.png

此时 module 的复用机制就体现出来优势了,我们可以复用先前构建出来的 module,而不必一次次的创建或者引用,例如 Drawin 组件,而保存这些缓存文件的位置就是前面章节里提到的保存 pcm 类型文件的地方。

先前我们提到了 clang 命令的参数会真正影响到 module 的唯一性,那具体的原理又是怎样的?

clang 会将相应的编译参数进行一次 hash,将获得的 hash 值作为 module 缓存文件夹的名称,这里需要注意的是,不同的参数和值会导致文件夹不同,所以想要尽可能的利用 module 缓存,就必须保证参数不发生变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ clang -fmodules —DENABLE_FEATURE=1 …
## 生成的目录如下
98XN8P5QH5OQ/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

$ clang -fmodules —DENABLE_FEATURE=2 …
## 生成的目录如下
1GYDULU5XJRF/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

这里我们大概了解了系统组件的 module 构建机制,这也是开启 Enable Modules(C and Objective-C) 的核心工作原理。

神秘的 Virtual File System(VFS)

对于系统组件,我们可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk/System/Library/Frameworks 目录里找到它的身影,它的目录结构大概是这样的

IMAGE

也就是说,对于系统组件而言,构建 module 的整个过程是建立在这样一个完备的文件结构上,即在 Framework 的 Modules 目录中查找 modulemap,在 Headers 目录中加载头文件。

那对于用户自己创建的组件,clang 又是如何构建 module 的呢?

通常我们的开发目录大概是下面的样子,它并没有 modules 目录,也没有 headers 目录,更没有 modulemap 文件,看起来和 framework 的文件结构也有着极大的区别。

IMAGE

在这种情况下,clang 是没法按照前面所说的机制去构建 module 的,因为在这种文件结构中,压根就没有 Modules 和 Headers 目录。

为了解决这个问题,clang 又提出了一个新的解决方案,叫做 Virtual File System(VFS)。

简单来说,通过这个技术,clang 可以在现有的文件结构上虚拟出来一个 Framework 文件结构,进而让 clang 遵守前面提到的构建准则,顺利完成 module 的编译,同时 VFS 也会记录文件的真实位置,以便在出现问题的时候,将文件的真实信息暴露给用户。

为了进一步了解 VFS,我们还是从 Build Log 中查找一些细节!

画板.png

在上面的编译参数里,我们可以找到一个 -ivfsoverlay 的参数,查看 help 说明,可以知道其作用就是向编译器传递一个 VFS 描述文件并覆盖掉真实的文件结构信息。

1
-ivfsoverlay <value>    Overlay the virtual filesystem described by file over the real file system

顺着这个线索,我们去看看这个参数指向的文件,它是一个 yaml 格式的文件,在将内容进行了一些裁剪后,它的核心内容如下,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"case-sensitive": "false",
"version": 0,
"roots": [
{
"name": "XXX/Debug-iphonesimulator/PodA/PodA.framework/Headers",
"type": "directory",
"contents": [
{ "name": "ClassA.h", "type": "file",
"external-contents": "XXX/PodA/PodA/Classes/ClassA.h"
},
......
{ "name": "PodA-umbrella.h", "type": "file",
"external-contents": "XXX/Target Support Files/PodA/PodA-umbrella.h"
}
]
},
{
"contents": [
"name": "XXX/Products/Debug-iphonesimulator/PodA/PodA.framework/Modules",
"type": "directory"
{ "name": "module.modulemap", "type": "file",
"external-contents": "XXX/Debug-iphonesimulator/PodA.build/module.modulemap"
}
]
}
]
}

结合前面提到的内容,我们不难看出它在描述这样一个文件结构:

借用一个真实存在的文件夹来模拟 framework 里的 Headers 文件夹,在这个 Headers 文件夹里有名为 PodA-umbrella.hClassA.h 等的文件,不过这几个虚拟文件与 external-contents 指向的真实文件相关联,同理还有 Modules 文件夹和它里面的 module.modulemap 文件。

通过这样的形式,一个虚拟的 framework 目录结构诞生了!此时 clang 终于能按照前面的构建机制为用户创建 module 了!

Swift 来了

没有头文件的 Swift

前面的章节我们聊了很多 C 语言系的预编译知识,在这个体系下,文件的编译是分开的,当我们想引用其他文件里的内容时,就必须引入相应的头文件。

画板.png

而对于 Swift 这门语言来说,它并没有头文件的概念,对于开发者而言,这确实省去了写头文件的重复工作,但这也意味着,编译器会进行额外的操作来查找接口定义并需要持续关注接口的变化!

为了更好的解释 Swift 和 Objective-C 是如何寻找到彼此的方法声明的,我们这里引入一个例子,在这个例子由三个部分组成:

  • 第一部分是一个 ViewController 的代码,它里面包含了一个 view,其中 PetViewController 和 PetView 都是 Swift 代码。
  • 第二部分是一个 App 的代理,它是 Objective-C 代码。
  • 第三个部分是一段单测代码,用来测试第一个部分中的 ViewController,它是 Swift 代码。
1
2
3
4
5
import UIKit
class PetViewController: UIViewController {
var view = PetView(name: "Fido", frame: frame)

}
1
2
3
4
#import "PetWall-Swift.h"
@implementation AppDelegate

@end
1
2
3
@testable import PetWall
class TestPetViewController: XCTestCase {
}

它们的关系大致如下所示:

画板.png

为了能让这些代码编译成功,编译器会面对如下 4 个场景:

首先是寻找声明,这包括寻找当前 target 内的方法声明(PetView),也包括来自 Objective-C 组件里的声明(UIViewController 或者 PetKit)。

然后是生成接口,这包括被 Objective—C 使用的接口,也包括被其他 target (Unit Test)使用的 Swift 接口。

第一步 - 如何寻找 Target 内部的 Swift 方法声明

在编译 PetViewController.swift 时,编译器需要知道 PetView 的初始化构造器的类型,才能检查调用是否正确。

此时编译器会加载 PetView.swift 文件并解析其中的内容, 这么做的目的就是确保初始化构造器真的存在,并拿到相关的类型信息,以便 PetViewController.swift 进行验证。

画板.png

编译器并不会对初始化构造器的内部做检查,但它仍然会进行一些额外的操作,这是什么意思呢?

与 clang 编译器不同的是,swiftc 编译的时候,会将相同 target 里的其他 swift 文件进行一次解析,用来检查其中与被编译文件关联的接口部分是否符合预期。

同时我们也知道,每个文件的编译是独立的,且不同文件的编译是可以并行开展的,所以这就意味着每编译一个文件,就需要将当前 target 里的其余文件当做接口,重新编译一次。 等于任意一个文件,在整个编译过程中,只有 1 次被作为生产 .o 产物的输入,其余时间会被作为接口文件反复解析。

画板.png

不过在 Xcode 10 以后,Apple 对这种编译流程进行了优化!

在尽可能保证并行的同时,将文件进行了分组编译,这样就避免了 group 内的文件重复解析,只有不同 group 之间的文件会有重复解析文件的情况。

画板.png

而这个分组操作的逻辑,就是刚才提到的一些额外操作。

至此,我们应该了解了 Target 内部是如何寻找 Swift 方法声明的了。

第二步 - 如何找到 Objective-C 组件里的方法声明

回到第一段代码中,我们可以看到 PetViewController 是继承自 UIViewController,而这也意味着我们的代码会与 Objective-C 代码进行交互,因为大部分系统库,例如 UIKit 等,还是使用 Objective-C 编写的。

在这个问题上,Swift 采用了和其他语言不一样的方案!

通常来说,两种不同的语言在混编时需要提供一个接口映射表,例如 JavaScript 和 TypeScript 混编时候的 .d.ts 文件,这样 TypeScript 就能够知道 JavaScript 方法在 TS 世界中的样子。

然而,Swift 不需要提供这样的接口映射表, 免去了开发者为每个 Objective-C API 声明其在 Swift 世界里样子,那它是怎么做到的呢?

很简单,Swift 编译器将 clang 的大部分功能包含在其自身的代码中,这就使得我们能够以 module 的形式,直接引用 Objective-C 的代码。

画板.png

既然是通过 module 的形式引入 Objective-C,那么 framework 的文件结构则是最好的选择,此时编译器寻找方法声明的方式就会有下面三种场景:

  • 对于大部分的 target 而言,当导入的是一个 Objective-C 类型的 framework 时,编译器会通过 modulemap 里的 header 信息寻找方法声明

  • 对于一个既有 Objective-C,又有 Swift 代码的 framework 而言,编译器会从当前 framework 的 umbrella header 中寻找方法声明,从而解决自身的编译问题,这是因为通常情况下 modulemap 会将 umbrella header 作为自身的 header 值。

  • 对于 App 或者 Unit Test 类型的 target,开发者可以通过为 target 创建 briding header 来导入需要的 Objective-C 头文件,进而找到需要的方法声明。

不过我们应该知道 Swift 编译器在获取 Objective-C 代码过程中,并不是原原本本的将 Objective—C 的 API 暴露给 Swift,而是会做一些 “Swift 化” 的改动,例如下面的 Objective-C API 就会被转换成更简约的形式。

画板.png

这个转换过程并不是什么高深的技术,它只是在编译器上的硬编码,如果感兴趣,可以在 Swift 的开源库中的找到相应的代码 - PartsOfSpeech.def

当然,编译器也给与了开发者自行定义 “API 外貌” 的权利,如果你对这一块感兴趣,不妨阅读我的另一篇文章 - WWDC20 10680 - Refine Objective-C frameworks for Swift,那里面包含了很多重塑 Objective-C API 的技巧。

不过这里还是要提一句,如果你对生成的接口有困惑,可以通过下面的方式查看编译器为 Objective-C 生成的 Swift 接口。

IMAGE

第三步 - Target 内的 Swift 代码是如何为 Objective-C 提供接口的

前面讲了 Swift 代码是如何引用 Objective-C 的 API,那么 Objective-C 又是如何引用 Swift 的 API 呢?

从使用层面来说,我们都知道 Swift 编译器会帮我们自动生成一个头文件,以便 Objective-C 引入相应的代码,就像第二段代码里引入的 PetWall-Swift.h 文件,这种头文件通常是编译器自动生成的,名字的构成是 组件名-Swift 的形式。

画板.png

但它到底是怎么产生的呢?

在 Swift 中,如果某个类继承了 NSObject 类且 API 被 @objc 关键字标注,就意味着它将暴露给 Objective-C 代码使用。

不过对于 App 和 Unit Test 类型的 target 而言,这个自动生成的 header 会包含访问级别为 public 和 internal 的 API,这使得同一 target 内的 Objective-C 代码也能访问 Swift 里 internal 类型的 API,这也是所有 Swift 代码的默认访问级别。

但对于 framework 类型的 target 而言,Swift 自动生成的头文件只会包含 public 类型的 API,因为这个头文件会被作为构建产物对外使用,所以像 internal 类型的 API 是不会包含在这个文件中。

注意,这种机制会导致在 framework 类型的 target 中,如果 Swift 想暴露一些 API 给内部的 Objective-C 代码使用,就意味着这些 API 也必须暴露给外界使用,即必须将其访问级别设置为 public 。

那么编译器自动生成的 API 到底是什么样子,有什么特点呢?

画板.png

上面是截取了一段自动生成的头文件代码,左侧是原始的 Swift 代码,右侧是自动生成的 Objective-C 代码,我们可以看到在 Objective-C 的类中,有一个名为 SWIFT_CLASS 的宏,将 Swift 与 Objective-C 中的两个类进行了关联。

如果你稍加注意,就会发现关联的一段乱码中还绑定了当前的组件名(PetWall),这样做的目的是避免两个组件的同名类在运行时发生冲突。

当然,你也可以通过向 @objc(Name) 关键字传递一个标识符,借由这个标识符来控制其在 Objective-C 中的名称,如果这样做的话,需要开发者确保转换后的类名不与其他类名出现冲突。

画板.png

这大体上就是 Swift 如何像 Objective-C 暴露接口的机理了,如果你想更深入的了解这个文件的由来,就需要看看第四步。

第四步 - Swift Target 如何生成供外部 Swift 使用的接口

Swift 采用了 Clang Module 的理念,并结合自身的语言特性进行了一系列的改进。

在 Swift 中,module 是方法声明的分发单位,如果你想引用相应的方法,就必须引入对应的 module,之前我们也提到了 swift 的编译器包含了 clang 的大部分内容,所以它也是兼容 clang module 的。

所以我们可以引入 Objective-C 的 module,例如 XCTest,也可以引入 Swift Target 生成的 module,例如 PetWall

1
2
3
4
5
6
7
8
import XCTest
@testable import PetWall
class TestPetViewController: XCTestCase {
func testInitialPet() {
let controller = PetViewController()
XCTAssertEqual(controller.view.name, "Fido")
}
}

在引入 swift 的 module 后,编译器会反序列化一个后缀名为 .swiftmodule 的文件,并通过这种文件里的内容来了解相关接口的信息。

例如,以下图为例,在这个单元测试中,编译器会加载 PetWall 的 module,并在其中找寻 PetViewController 的方法声明,由此确保其创建行为是符合预期的。

画板.png

这看起来很像第一步中 target 寻找内部 Swift 方法声明的样子,只不过这里将解析 swift 文件的步骤,换成了解析 swiftmodule 文件而已。

不过需要注意的是,这个 swfitmodule 文件并不是文本文件,它是一个二进制格式的内容,通常我们可以在构建产物的 Modules 文件夹里寻找到它的身影。

IMAGE

在 target 的编译的过程中,面向整个 target 的 swiftmodule 文件并不是一下产生的,每一个 swift 文件都会生成一个 swiftmodule 文件,编译器会将这些文件进行汇总,最后再生成一个完整的,代表整个 target 的 swiftmodule,也正是基于这个文件,编译器构造出了用于给外部使用的 Objective-C 头文件,也就是第三步里提到的头文件

画板.png

不过随着 Swift 的发展,这一部分的工作机制也发生了些许变化。

我们前面提到的 swiftmodule 文件是一种二进制格式的文件,而这个文件格式会包含一些编译器内部的数据结构,不同编译器产生的 swiftmodule 文件是互相不兼容的,这也就导致了不同 Xcode 构建出的产物是无法通用的,如果对这方面的细节感兴趣,可以阅读 Swift 社区里的两篇官方 Blog:Evolving Swift On Apple Platforms After ABI StabilityABI Stability and More,这里就不展开讨论了。

为了解决这一问题,Apple 在 Xcode 11 的 Build Setting 中提供了一个新的编译参数 Build Libraries for Distribution,正如这个编译参数的名称一样,当我们开启它后,构建出来的产物不会再受编译器版本的影响,那它是怎么做到这一点的呢?

为了解决这种对编译器的版本依赖,Xcode 在构建产物上提供了一个新的产物,swiftinterface 文件。

IMAGE

这个文件里的内容和 swiftmodule 很相似,都是当前 module 里的 API 信息,不过 swiftinterface 是以文本的方式记录,而非 swiftmodule 的二进制方式。

这就使得 swiftinterface 的行为和源代码一样,后续版本的 swift 编译器也能导入之前编译器创建的 swiftinterface 文件,像使用源码的方式一样使用它。

为了更进一步了解它,我们来看看 swiftinterface 的真实样子,下面是一个 .swift 文件和 .swiftinterface 文件的比对图。

画板.png

在 swiftinterface 文件中,有以下点需要注意

  • 文件会包含一些元信息,例如文件格式版本,编译器信息,和 Swift 编译器将其作为模块导入所需的命令行子集。
  • 文件只会包含 public 的接口,而不会包含 private 的接口,例如 currentLocation
  • 文件只会包含方法声明,而不会包含方法实现,例如 Spacesship 的 init,fly 等方法
  • 文件会包含所有隐式声明的方法,例如 Spacesship 的 deinit 方法 ,Speed 的 Hashable 协议

总的来说,swiftinterface 文件会在编译器的各个版本中保持稳定,主要原因就是这个接口文件会包含接口层面的一切信息,不需要编译器再做任何的推断或者假设。

好了,至此我们应该了解了 Swift Target 是如何生成供外部 Swift 使用的接口了。

这四步意味着什么?

此 module 非彼 module

通过上面的例子,我想大家应该能清楚的感受到 swift module 和 clang module 不完全是一个东西,虽然它们有很多相似的地方。

clang module 是面向 C 语言家族的一种技术,通过 modulemap 文件来组织 .h 文件中的接口信息,中间产物是二进制格式的 pcm 文件。

swift module 是面向 Swift 语言的一种技术,通过 swiftinterface 文件来组织 .swift 文件中的接口信息,中间产物二进制格式的 swiftmodule 文件。

画板.png

所以说理清楚这些概念和关系后,我们在构建 Swift 组件的产物时,就会知道哪些文件和参数不是必须的了。

例如当你的 Swift 组件不想暴露自身的 API 给外部的 Objective-C 代码使用的话,可以将 Build Setting 中 Swift Compiler - General 里的 Install Objective-C Compatiblity Header 参数设置为 NO,其编译参数为 SWIFT_INSTALL_OBJC_HEADER,此时不会生成 <ProductModuleName>-Swift.h 类型的文件,也就意味着外部组件无法以 Objective-C 的方式引用组件内 Swift 代码的 API。

IMAGE

而当你的组件里如果压根就没有 Objective-C 代码的时候,你可以将 Build Setting 中 Packaging 里 Defines Module 参数设置为 NO,其编译参数为 DEFINES_MODULE, 此时不会生成 <ProductModuleName>.modulemap 类型的文件。

IMAGE

Swift 和 Objective-C 混编的三个“套路”

基于刚才的例子,我们应该理解了 swift 在编译时是如何找到其他 API 的,以及它又是如何暴露自身 API 的,而这些知识就是解决混编过程中的基础知识,为了加深影响,我们可以将其绘制成 3 个流程图

当 Swift 和 Objective-C 文件同时在一个 App 或者 Unit Test 类型的 target 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件在不同 target 中,例如不同 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件同时在一个target 中,例如同一 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

对于第三个流程图,需要做以下补充说明

  • 由于 swiftc,也就是 swift 的编译器,包含了大部分的 clang 功能,其中就包含了 clang module,借由组件内已有的 modulemap 文件,swift 编译器就可以轻松找到相应的 Objective-C 代码。
  • 相比于第二个流程而言,第三个流程中的 modulemap 是组件内部的,而第二个流程中,如果想引用其他组件里的 Objective-C 代码,需要引入其他组件里的 modulemap 文件才可以
  • 所以基于这个考虑,并未在流程 3 中标注 modulemap。

构建 Swift 产物的新思路

在前面的章节里,我们提到了 Swift 找寻 Objective-C 的方式,其中提到了,除了 App 或者 Unit Test 类型的 target 外,其余的情况下都是通过 framework 的 module map 来寻找 Objective-C 的 API,那么如果我们不想使用 framework 的形式呢?

目前来看,这个在 Xcode 中是无法直接实现的,原因很简单,Build Setting 中 Search Path 选项里并没有 modulemap 的 search path 配置参数。

IMAGE

为什么一定需要 modulemap 的 search path 呢?

基于前面了解到的内容,swiftc 包含了 clang 的大部分逻辑,在预编译方面,swiftc 只包含了 clang module 的模式,而没有其他模式,所以 Objective-C 想要暴露自己的 API 就必须通过 modulemap 来完成。

而对于 Framework 这种标准的文件夹结构,modulemap 文件的相对路径是固定的,它就在 Modules 目录中,所以 Xcode 基于这种标准结构,直接内置了相关的逻辑,而不需要将这些配置再暴露出来。

从组件的开发者角度来看,他只需要关心 modulemap 的内容是否符合预期,以及路径是否符合规范。

从组件的使用者角度来看,他只需要正确的引入相应的 Framework 就可以使用到相应的 API。

这种只需要配置 Framework 的方式,避免了配置 header search path,也避免了配置 static library path,可以说是一种很友好的方式,如果再将 modulemap 的配置开放出来,反而显得多此一举。

那如果我们抛开 Xcode,抛开 Framework 的限制,还有别的办法构建 Swift 产物么?

答案是肯定有的,这就需要借助前面所说的 VFS 技术!

假设我们的文件结构如下所示:

1
2
3
4
5
6
7
8
9
├── LaunchPoint.swift
├── README.md
├── build
├── repo
│ └── MyObjcPod
│ └── UsefulClass.h
└── tmp
├── module.modulemap
└── vfs-overlay.yaml

其中 LaunchPoint.swift 引用了 UsefulClass.h 中的一个公开 API,并产生了依赖关系。

另外,vfs-overlay.yaml 文件重新映射了现有的文件目录结构,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
'version': 0,
'roots': [
{ 'name': '/MyObjcPod', 'type': 'directory',
'contents': [
{ 'name': 'module.modulemap', 'type': 'file',
'external-contents': 'tmp/module.modulemap'
},
{ 'name': 'UsefulClass.h', 'type': 'file',
'external-contents': 'repo/MyObjcPod/UsefulClass.h'
}
]
}
]
}

至此,我们通过如下的命令,便可以获得 LaunchPoint 的 swiftmodule,swiftinterface 等文件,具体的示例可以查看我在 github 上的链接 - manually-expose-objective-c-API-to-swift-example

1
swiftc -c LaunchPoint.swift -emit-module -emit-module-path build/LaunchPoint.swiftmodule -module-name index -whole-module-optimization -parse-as-library -o build/LaunchPoint.o -Xcc -ivfsoverlay -Xcc tmp/vfs-overlay.yaml -I /MyObjcPod

那这意味着什么呢?

这就意味着,只提供相应的 .h 文件和 .modulemap 文件就可以完成 Swift 二进制产物的构建,而不再依赖 Framework 的实体。同时,对于 CI 系统来说,在构建产物时,可以避免下载无用的二进制产物(.a 文件),这从某种程度上会提升编译效率。

如果你没太理解上面的意思,我们可以展开说说。

例如,对于 PodA 组件而言,它自身依赖 PodB 组件,在使用原先的构建方式时,我们需要拉取 PodB 组件的完整 Framework 产物,这会包含 Headers 目录,Modules 目录里的必要内容,当然还会包含一个二进制文件(PodB),但在实际编译 PodA 组件的过程中,我们并不需要 B 组件里的二进制文件,而这让拉取完整的 Framework 文件显得多余了。

IMAGE

而借助 VFS 技术,我们就能避免拉取多余的二进制文件,进一步提升 CI 系统的编译效率。

总结

感谢你的耐心阅读,至此,整篇文章终于结束了,通过这篇文章,我想你应该:

  • 理解 Objective-C 的三种预编译的工作机制,其中 clang module 做到了真正意义上的语义引入,提升了编译的健壮性和扩展性。
  • 在 Xcode 的 search path 的各种技术细节使用到了 hmap 技术,通过加载映射表的方式避免了大量重复的 IO 操作,可以提升编译效率。
  • 在处理 Framework 的头文件索引时,总是会先搜索 Headers 目录,再搜索 PrivateHeader 目录
  • 理解 Xcode Phases 构建系统中,Public 代表公开头文件,Private 代表不需要使用者感知,但物理存在的文件, 而 Project 代表不应让使用者感知,且物理不存在的文件。
  • 不使用 Framework 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,在 CocoaPods 上使用 hmap 并不会提升编译速度。
  • 通过 cocoapods-hmap-built 插件,可以将大型项目的全链路时长节省 45% 以上,Xcode 打包环节的时长节省 50% 以上。
  • clang module 的构建机制确保了其不受上下文影响(独立编译空间),复用效率高(依赖决议),唯一性(参数哈希化)
  • 系统组件通过已有的 Framework 文件结构实现了构建 module 的基本条件 ,而非系统组件通过 VFS 虚拟出相似的 Framework 文件 结构,进而具备了编译的条件。
  • 可以粗浅的将 Clang Module 里的 .h/m.moduelmap.pch 的概念对应为 Swift Module 里的 .swift.swiftinterface.swiftmodule 的概念
  • 理解三种具有普适性的 Swift 与 Objective-C 混编方法
    • 同一 target 内(App 或者 Unit 类型),基于 <PorductModuleName>-Swift.h<PorductModuleName>-Bridging-Swift.h
    • 同一 target 内,基于 <PorductModuleName>-Swift.h 和 clang 自身的能力
    • 不同 target 内,基于 <PorductModuleName>-Swift.hmodule.modulemap
  • 利用 VFS 机制构建,可以在构建 Swift 产物的过程中避免下载无用的二进制产物,进一步提升编译效率

最后,在编写这篇文章的过程中,我的同事 @叶樉 和 @宋旭陶 给与了我许多指导与帮助,也正是在大家的共同努力下,才有了这篇文章,希望它能对亲爱的读者您,有所帮助!

参考文档

作者简介

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

cocoapods-hmap-prebuilt - 一款可以让大型 iOS 工程编译速度提升 50% 的工具

这篇文章是我早年在美团技术博客上发布的一篇文章, 部分内容可能已经过时, 请开发者注意.
将文章同步到个人博客主要是为了同步和备份.

cocoapods-hmap-prebuilt 是什么?

cocoapods-hmap-prebuilt 是美团平台迭代组自研的一款 cocoapods 插件,以 Header Map 技术 为基础,进一步提升代码的编译速度,完善头文件的搜索机制。

虽然以二进制组件的方式构建 App 是 HPX (公司移动端统一持续集成/交付平台)的主流解决方案,但在某些场景下(Profile、Address/Thread/UB/Coverage Sanitizer、App 级别静态检查、ObjC 方法调用兼容性检查等等等等),我们的构建工作还是需要以全源码编译的方式进行;再结合实际开发过程中,大多是以源码的方式开发,所以我们将实验对象设置为基于全源码编译的流程。

废话不多说,我们来看看它的实际使用效果!

总的来说,以美团和大众点评的全源码编译流程为实验对象的前提下,cocoapods-hmap-prebuilt 插件能将总链路提升 45% 以上的速度,在 Xcode 打包环节上能提升 50% 以上的速度,是不是有点动心了?

为了更好的理解这个插件的价值和功能,我们不妨先理解一下当前的工程中存在的问题!

为什么现有的项目不够好?

目前公司内的 App 都是基于 CocoaPods 做包管理方面的工作,所以在实际的开发过程中,CocoaPods 会在 Pods/Header/ 目录下添加组件名目录和头文件软链,类似于下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/Users/sketchk/Desktop/MyApp/Pods
└── Headers
├── Private
│ └── AFNetworking
│ ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
│ ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
│ ├── ...
│ └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h
└── Public
└── AFNetworking
   ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
   ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
   ├── ...
   └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h

也正是通过这样的目录结构和软链,CocoaPods 得以在 Header Search Path 中添加如下的参数,使得预编译环节顺利进行。

1
2
3
4
5
$(inherited)
${PODS_ROOT}/Headers/Private
${PODS_ROOT}/Headers/Private/AFNetworking
${PODS_ROOT}/Headers/Public
${PODS_ROOT}/Headers/Public/AFNetworking

虽然这种构建 search path 的方式解决了预编译的问题,但在某些项目中,例如多达 400+ 组件的巨型项目中,会造成以下几点问题:

  1. 大量的 header search path 路径,会造成编译参数中的 -I 选项极速膨胀,在达到一定长度后,甚至会造成无法编译的情况
  2. 目前美团的工程中,已经有近 5W 个头文件,这意味着不论是头文件的搜索过程,还是软链的创建过程,都会引起大量的文件 IO 操作,进而会产生一些耗时操作。
  3. 编译时间会随着组件数量急剧增长,以美团和大众点评有 400+ 个组件的体量为参考,全源码打包耗时均为 1 小时以上。
  4. 基于路径顺序查找头文件的方式有潜在的风险,例如重名头文件的情况,排在后面的头文件永远无法参与编译
  5. 由于 ${PODS_ROOT}/Headers/Private 路径的存在,让引用其他组件的私有头文件变为了可能。

上面的问题,好一点的不过是浪费了 1 个小时而已,而不好的情况则是让有风险的代码上线了,你说开发者头疼不头疼?

Header Map 是个啥?

还好 cocoapods-hmap-prebuilt 的出现,让这些问题变成了历史,不过要想理解它为什么能解决这些问题,我们得先理解一下什么是 Header Map!

Header Map 其实是一组头文件信息映射表!

为了更直观的理解 Header Map,我们可以在 build setting 中开启 Use Header Map 选项,真实的体验一下它。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c some-file.m -o some-file.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

IMAGE

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件,后面还有个括号写着 headermap。

没错!它就是 Header Map 的实体。

此时 clang 已经在刚才提到的 hmap 文件里塞入了一份头文件名和头文件路径的映射表,不过它是一种二进制格式的文件,为了验证这个的说法,我们可以通过 milend 编写的hmap 工具来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下, 类似于一个 key-value 的形式,key 值是头文件的名称,value 是头文件的实际物理路径:

IMAGE

需要注意,映射表的键值内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式下,还是 <...> 的形式下,又或是在 Build Phase 里 Header 的配置情况。例如,你将头文件设置为 public 的时候,在某些 hmap 中,它的 key 值就为 PodA/ClassA,而将其设置为 project 的时候,它的 key 值可能就是 ClassA,而配置这些信息的地方,如下图所示:

IMAGE

至此我想你应该了解到 Header Map 到底是个什么东西了。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

此时,我估计你可能并不会对 buck 产生太多的兴趣,而是开始思考上一张图中 Headers 的 public,private,project 到底代表着什么意思,好像我从来没怎么关注过,以及为什么它会影响 hmap 里的内容?

Public,Private,Project 是个啥?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

此时,你会不会联想到 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

画板.png

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实看起来,CocoaPods 对于 public 和 private 的官方解释是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 Ready 的头文件,更像一个 In Progress 的含义。

这一块是不是有点让人大跌眼镜,那么,在现实世界中,我们是否正确的使用了它们呢?

为什么用原生的 hmap 不能改善编译速度?

前面我们介绍了 hmap 是什么,以及怎么开启它(启用 Build Setting 中的 Use Header Map 选项),也介绍了一些影响生成 hmap 的因素(Public,Private,Project)

那是不是我只要开启 Xcode 提供的 Use Header Map 就可以提升编译速度了呢?

很可惜,答案是不行的!

至于原因,我们就从下面的例子开始说起,假设我们有一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

  • 首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。
  • 其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

IMAGE

当构建的产物类型为 Static Library 的时候,CocoaPods 在创建头文件产物过程中,它的逻辑大致如下:

  • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
  • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
  • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
  • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,会导致一些有意思的问题发生。

  • 首先,由于所有头文件都被当做最终产物保留下来,在结合 header search path 里 Pods/Headers/Private 路径的存在,我们完全可以引用到其他组件里的私有头文件,例如我只要使用 #import <SomePod/Private_Header.h> 的方式,就会命中私有文件的匹配路径。
  • 其次,就是在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "ClassA.h" 的键值引用,也就是说只有 #import "ClassA.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径,例如下图中的 PodB,在其 build 的过程中,Xcode 会为 PodB 生成 5 个 hmap 文件,也就是说这 5 个文件只会在编译 PodB 中使用,其中 PodB 会依赖 PodA 的一些头文件,但由于 PodA 中的头文件都是 Project 类型的,所以其在 hmap 里的 key 全部为 ClassA.h ,也就是说我们只能以 #import "ClassA.h" 的方式引入。

IMAGE

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 选项并不会帮我们提升编译速度。

但真的就没有办法使用 Header Map 了么?

cocoapods-hmap-prebuilt 诞生了

当然,总是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件,正是基于这个想法,美团自研的 cocoapods-hmap-prebuilt 插件诞生了!

它的核心功能并不多,大概有以下几点:

  • 借助 CocodPods 处理 Header Search Path 和创建头文件 soft link 的时机,构建了头文件索引表并以此生成 n+1 个 hmap 文件(n 是每个组件自己的 private header 信息,1 是所有组件公共的 public header 信息)
  • 重写 xcconfig 文件里的 header search path 到对应的 hmap 文件上,一条指向组件自己的 private hmap,一条指向所有组件共用的 public hmap。
  • 针对 public hmap 里的重名头文件进行了特殊处理,只允许保存组件名/头文件名方式的 key-value,排查重名头文件带来的异常行为。
  • 将组件自身的 Ues Header Map 功能关闭,减少不必要的文件创建和读取

听起来可能有点绕,内容也有点多,不过这些你都不用关心,你只需要通过以下 2 个步骤就能将其使用起来:

  1. 在 Gemfile 里声明插件
  2. 在 Podfile 里使用插件
1
2
3
4
5
6
7
8
9
10
11
12
13
// this is part of Gemfile
source 'http://sakgems.sankuai.com/' do
gem 'cocoapods-hmap-prebuilt'
gem 'XXX'
...
end

// this is part of Podfile
target 'XXX' do
plugin 'cocoapods-hmap-prebuilt'
pod 'XXX'
...
end

除此之外,为了拓展其实用性,我们还提供了头文件补丁(解决重名头文件的定向选取)和环境变量注入(无侵入的在其他系统中使用)的能力,便于其在不同场景下的使用。

总结

至此,关于 cocoapods-hmap-prebuilt 的介绍就要结束了。

回看整个故事的开始,Header Map 是我在研究 Swift 和 Objective-C 混编过程中发现的一个很小的知识点,而且 Xcode 自身就实现了一套基于 Header Map 的功能,在实际的使用过程中,它的表现并不理想。

但幸运的是,在后续的探索的过程中,我们发现了为什么 Xcode 的 Header Map 没有生效,以及为什么它与 CocoaPods 出现了不兼容的情况,虽然它的原理并不复杂,核心点就是将文件查找和读取等 IO 操作编变成了内存读取操作,但结合实际的业务场景,我们发现它的收益是十分可观的。

或许这是在提醒我们,要永远对技术保持一颗好奇的心!

最后,非常感谢 @宋旭陶 同学在工作之余,和我一起完成了 cocoapods-hmap-prebuilt 插件的开发工作,也非常感谢 @叶樉 同学,在我困惑的时候给出很多富有建设性的指导和意见。

其实利用 clang module 技术也可以解决本文一开始提到的几个问题,但它并不在这篇文章的讨论范围中,如果你对 clang module 或者对 Swift 与 Objective-C 混编感兴趣,欢迎阅读参考文档中的 《从预编译的角度理解 Swift 与 Objective-C 及混编机制》一文,以了解更多的详细信息。

参考文档

作者

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

编译系统(Compilation System)和编译流程(Compilation pipeline)到底是什么

编译到底是什么一文中,我们了解了编译在计算机编程中扮演的角色和作用,这次我们将探讨编译系统是由哪些角色组成的!

回顾

从某种程度上来说:

编译,其实就是把源代码变成目标代码的过程。如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。

组成编译系统的基本元素

通常来说,编译系统是由 4 个部分组成

  • 预处理(preprocessor):负责引用 header file,libraries 的文件并组成完整的代码,以 C 语言为例,就是根据 # 开头的指令,例如 #include <stdio.h> 插入 stdio.h 的内容

  • 编译器(compiler):把重新组合好的代码再转交给 compiler,并转化成汇编语言(assembly language),使用汇编语言最重要的原因是不同的高级语言都可以变成汇编语言,汇编码也比机器码好 debug,PS:汇编语言会因硬件的架构而有所不同。

  • 汇编器(assembler):将汇编语言转换为机器码(machine code),并打包成重新定位的目标文件(object file)

  • 链接器(linker):负责合并所有的 object file 并产生可执行的文件,它可以被加载到内存中执行。

我们再把流程绘制成如下的图示:

IMAGE

编译器的组成

知道了编译系统后,我们再探究下编译系统里面编译器(compiler)环节。

按照龙书里的说法,我们可以将编译器里做的事情分为两个阶段:

  1. 分析(Analysis): 又称为 Compiler 的前端处理(front-end),分析与解构原始代码,并将其整理成中间代码(intermediate representation)与符号表(symbol table)并传给下一个阶段,当中如果发现任何问题就会提示报错
  2. 生成(Synthesis):又称为 compiler 的后端处理(back-end),根据符号表与中间代码产出目标代码

为了理解编译器的作用,我举一个很简单的例子。这里有一段 C 语言的程序,我们一起来看看它的编译过程。

1
2
3
4
int foo(int a){
int b = a + 3;
return b;
}

这段源代码,如果把它编译成汇编代码,大致是下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
    .section    __TEXT,__text,regular,pure_instructions
.globl _foo ## -- Begin function foo
_foo: ## @foo
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
addl $3, %eax
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
popq %rbp
retq

你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?

其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。

表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,

所以,我们将编译器的两个工作环节进行更细致的划分

  • 分析 (analysis):
    • 词法分析器 (lexical analyzer),也称 scanner,建立 symbol table
    • 语法分析器 (syntax analyzer),也称 parser
    • 语义分析器 (semantic analyzer)
    • 生成中间码 (intermediate code generator)
    • 中间码最佳化 (code optimizer) (optional & machine-independent)
  • 生成 (synthesis):
    • 目标代码生成器 (code generator)
    • 目标代码最佳化 (machine-independent code optimizer) (optional & machine-independent)

流程图如下:

因为 Symbol Table 会在各个步骤都会使用到,因此将其独立画出来

IMAGE

当然国外也有人将其这样划分

IMAGE

现代语言,在语法解析过程,是可以不依赖符号表的,所以符号表一般都推迟到语义分析阶段才去建立,例如 Java 语言。
当然也有个别语言,在语法解析阶段需要查一下符号表(也就是获取上下文信息),才能知道某个标识符应该位于哪个语法中。所以,这个时候符号表会提前建立,在词法分析的时候就开始建立。

词法分析

首先,编译器要读入源代码。

在编译之前,源代码只是一长串字符而已,这显然不利于编译器理解程序的含义。所以,编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。把字符串转换为 Token 的这个过程,就叫做词法分析。

IMAGE

把字符串转换为 Token(注意:其中的空白字符,代表空格、tab、回车和换行符,EOF 是文件结束符)

语法分析

识别出 Token 以后,离编译器明白源代码的含义仍然有很长一段距离。下一步,我们需要让编译器像理解自然语言一样,理解它的语法结构。这就是第二步,语法分析。

上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。

IMAGE

那么在编译器里,语法分析阶段也会把 Token 串,转换成一个体现语法规则的、树状的数据结构,这个数据结构叫做抽象语法树(AST,Abstract Syntax Tree)。我们前面的示例程序转换为 AST 以后,大概是下面这个样子:

IMAGE

这样的一棵 AST 反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0 到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。

再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的 Token(图中带边框的节点)。对这棵 AST 做深度优先的遍历,你就能依次得到原来的 Token。

语义分析

生成 AST 以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。

比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量 a 的值,把它跟字面量 3 的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。

IMAGE

这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。

那么,怎样理解源代码的语义呢

实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在 ECMAScript(也就是 JavaScript)标准 2020 版中,Semantic 这个词出现了 657 次。下图是其中加法操作的语义规则,它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。

IMAGE

ECMAScript 标准中加法操作的语义规则

所以,我们可以在每个 AST 节点上附加一些语义规则,让它能反映语言设计者的本意。

  • add 节点:把两个子节点的值相加,作为自己的值;
  • 变量节点(在等号右边的话):取出变量的值;
  • 数字字面量节点:返回这个字面量代表的值。

这样的话,如果你深度遍历 AST,并执行每个节点附带的语义规则,就可以得到 a+3 的值。这意味着,我们正确地理解了这个表达式的含义。运用相同的方法,我们也就能够理解一个句子的含义、一个函数的含义,乃至整段源代码的含义。

这也就是说,AST 加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历 AST,并且一边遍历,一边执行语法规则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于 AST 的解释器。

不过在此之前,编译器还要做点语义分析工作。那么这里的语义分析是要解决什么问题呢?

给你举个例子,如果我把示例程序稍微变换一下,加一个全局变量的声明,这个全局变量也叫 a。那你觉得“a+3”中的变量 a 指的是哪个变量?

1
2
3
4
5
int a = 10;       //全局变量
int foo(int a){ //参数里有另一个变量a
int b = a + 3; //这里的a指的是哪一个?
return b;
}

我们知道,编译程序要根据 C 语言在作用域方面的语义规则,识别出“a+3”中的 a,所以这里指的其实是函数参数中的 a,而不是全局变量的 a。这样的话,我们在计算“a+3”的时候才能取到正确的值。

而把“a+3”中的 a,跟正确的变量定义关联的过程,就叫做引用消解(Resolve)。这个时候,变量 a 的语义才算是清晰了。

变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。

引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,语义分析的重要特点,就是做上下文相关的分析。

在语义分析阶段,编译器还会识别出数据的类型。比如,在计算“a+3”的时候,我们必须知道 a 和 3 的类型是什么。因为即使同样是加法运算,对于整型和浮点型数据,其计算方法也是不一样的。

语义分析获得的一些信息(引用消解信息、类型信息等),会附加到 AST 上。这样的 AST 叫做带有标注信息的 AST(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。

IMAGE

好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。

在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。

计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量 b 的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。

总结起来,在语义分析阶段,编译器会做语义理解和语义检查这两方面的工作。词法分析、语法分析和语义分析,统称编译器的前端,它完成的是对源代码的理解工作。

做完语义分析以后,接下来编译器要做什么呢

本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的 AST、符号表等数据结构。

生成目标代码的工作,叫做后端工作。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成机器码。

熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的 CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。

中间代码

中间代码(IR),是处于源代码和目标代码之间的一种表示形式。

我们倾向于使用 IR 有两个原因。

第一个原因,是很多解释型的语言,可以直接执行 IR,比如 Python 和 Java。这样的话,编译器生成 IR 以后就完成任务了,没有必要生成最终的汇编代码。

第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于 IR,用统一的算法来完成。

优化

那为什么需要做优化工作呢?这里又有两大类的原因。

第一个原因,是源语言和目标语言有差异。源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。

比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧?用了 13 个单词,但它实际上是诗经里的“执子之手,与子偕老”对应的英文。这样看来,还是中国文言文承载信息的效率更高。

同样的情况在编程语言里也有。以 Java 为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:

1
2
3
4
5
6
7
8
9
Class Person{
private String name;
public String getName(){
return name;
}
public void setName(String newName){
this.name = newName
}
}

如果你在程序里用“person.getName()”来获取 Person 的 name 字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#调用者的代码
保存寄存器1 #保存现有寄存器的值到内存
保存寄存器2
...
保存寄存器n

把返回地址入栈
把person对象的地址写入寄存器,作为参数
跳转到getName函数的入口

#_getName 程序
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
从该地址获取值,放到一个用于保存返回值的寄存器
跳转到返回地

你看了这段伪代码,就会发现,简单的一个 getName() 方法,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。

怎样简化呢?就是跳过方法的调用。我们直接根据对象的地址计算出 name 属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。

这种优化方法就叫做内联(inlining),也就是把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中。在 Java 语言里,这种属性读写的代码非常多。所以,Java 的 JIT 编译器(把字节码编译成本地代码)很重要的工作就是实现内联优化,这会让整体系统的性能提高很大的一个百分比!

总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。

第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正。比如下面这段代码中的 bar() 函数,里面就有多个地方可以优化。甚至,整个对 bar() 函数的调用,也可以省略,因为 bar() 的值一定是 101。这些优化工作都可以在编译期间完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
int bar(){
int a = 10*10; //这里在编译时可以直接计算出100这个值,这叫做“常数折叠”
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”


if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
return a+1; //这里可以在编译器就计算出是101
}
else{
return a-1;
}
}
int a = bar(); //这里可以直接换成 a=101

综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。

IMAGE

而采用中间代码来编写优化算法的好处,是可以把大部分的优化算法,写成与具体 CPU 架构无关的形式,从而大大降低编译器适配不同 CPU 的工作量。并且,如果采用像 LLVM 这样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了。

生成目标代码

编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。

第一,是要选择合适的指令,生成性能最高的代码。

第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快 100 倍左右。

第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用 CPU 内部的多个功能部件的并行计算能力。

目标代码生成以后,整个编译过程就完成了。

编译器流程总结

IMAGE

Swift 编译器的不同之处

我们可以在 Swift 官网看到一篇名为 Swift Compiler 的文章,它从比较高的层面讲述了 Swift 编译器的主要流程,这里我们直接通过翻译的方式来介绍这些步骤:

  • 解析(Parsing):解析器是一个简易的递归下降解析器(在 lib/Parse 中实现),并带有完整手动编码的词法分析器。这个分析器会生成 AST,但不包含任何语义信息或者类型信息,并且会忽略源码上的语法错误。

  • 语义分析(Semantic Analysis):语义分析阶段(在 lib/Sema 中实现)负责获取已解析的 AST(抽象语法树)并将其转换为格式正确且类型检查完备的 AST,以及在源代码中提示出现语义问题的警告或错误。语义分析包含类型推断,如果可以成功推导出类型,则表明此时从已经经过类型检查的最终 AST 生成代码是安全的。

  • Clang 导入器(Clang Importer):Clang 导入器(在 lib/ClangImporter 中实现)负责导入 Clang 模块,并将导出的 C 或 Objective-C API 映射到相应的 Swift API 中。最终导入的 AST 可以被语义分析引用。

  • SIL 生成(SIL Generation):Swift 中间语言(Swift Intermediate Language,SIL)是一门高级且专用于 Swift 的中间语言,适用于对 Swift 代码的进一步分析和优化。SIL 生成阶段(在 lib/SILGen 中实现)将经过类型检查的 AST 弱化为所谓的「原始」SIL。SIL 的设计在 docs/SIL.rst 有所描述。

  • SIL 保证转换(SIL Guaranteed Transformations):SIL 保证转换阶段(在 lib/SILOptimizer/Mandatory 中实现)负责执行额外且影响程序正确性的数据流诊断(比如使用未初始化的变量)。这些转换的最终结果是「规范」SIL。

  • SIL 优化(SIL Optimizations):SIL 优化阶段(在 lib/Analysis、lib/ARC、lib/LoopTransforms 以及 lib/Transforms 中实现)负责对程序执行额外的高级且专用于 Swift 的优化,包括(例如)自动引用计数优化、去虚拟化、以及通用的专业化。

  • LLVM IR 生成(LLVM IR Generation):IR 生成阶段(在 lib/IRGen 中实现)将 SIL 弱化为 LLVM LR,此时 LLVM 可以继续优化并生成机器码。

从原文的内容里,我们可以看到 Swift 编译器主要是在前端部分增加了一些环节,主要是在语义分析和中间代码生成的过程中增加了几个步骤:

IMAGE

这一部分后续会继续完善,目前的水平和实践还只能到解释这么多

今天的内容就先到这了,希望通过这篇文章你能对编译系统,编译流程和编译环节做的事情有了一个初步的概念!

参考文献

编译到底是什么?

因为工作原因,最近要做包管理工具方面的开发,需要对 Compiler 有一些最基本的理解,写这篇文章的目的有两个:

  • 为了记录和整理自己的近期的学习内容,方便日后查阅
  • 抛开大段代码和抽象概念,通过通俗易懂的写作方式来加深自己对这些概念的理解

废话不多说,我们一起看看内容吧!

需要了解的概念

在看了不少关于编译相关的文章之后,我发现下面的词汇是大量出现的。

知道这些词汇代表的意思,以及对应的层次,能够更好地看懂别人所要表达的意思。

高级语言代码 High-Level Code

高级语言代码,自然是指由高级编程语言编写代码,对计算机的细节有更高层次的抽象。

相对于低级编程语言(low-level programming language)更接近自然语言(人类的语言),集成一系列的自动工具(垃圾回收,内存管理等),会让程序员更快乐的编写出更简洁,更易读的程序代码。

低级语言代码 Low-Level Code

低级语言代码,指由低级编程语言编写的代码,相对高级语言,少了更多的抽象概念,更加接近于汇编或者机器指令。但是这也意味着代码的可移植性很差。

在我看来,高与低,只是一组相对词而已。越高级的语言,性能、自由度越不及低级语言。但是在抽象、可读可写性、可移植性越比低级语言优秀。
在以前的年代,C/C++语言相对汇编语言,机器指令来说,肯定是高级语言。

而到了今天,我们更多人对C语言偏向认知为「低级语言」。
或许未来世界的开发者,看我们现在所熟悉的Java、PHP、Python、ECMAScript等等,都是「low」到爆的语言。

汇编语言 Assembly Language

汇编语言作为一门低级语言,对应于计算机或者其他可编程的硬件。它和计算机的体系结构以及机器指令是强关联的。换句话说,就是不同的汇编语言代码对应特定的硬件,所以不用谈可移植性了。

相对于需要编译和解释的高级语言代码来说,汇编代码只需要翻译成机器码就可以执行了。所以汇编语言也往往被称作象征性机器码(symbolic machine code)

字节码 Byte Code

字节码严格来说不算是编程语言,而是高级编程语言为了种种需求(可移植性、可传输性、预编译等)而产生的中间码(Intermediate Code)。它是由一堆指令集组成的代码,例如在 javac 编译过后的 java 源码产生的就是字节码。

源码在编译的过程中,是需要进行「词法分析 → 语法分析 → 生成目标代码」等过程的,在预编译的过程中,就完成这部分工作,生成字节码。
然后在后面交由解释器(这里通常指编程语言的虚拟机)解释执行,省去前面预编译的开销。

机器码 Machine Code

机器码是一组可以直接被 CPU 执行的指令集,每一条指令都代表一个特定的任务,或者是加载,或者是跳转,亦或是计算操作等等。所有可以直接被 CPU 执行的程序,都是由这么一系列的指令组成的。

机器码可是看作是编译过程中,最低级的代码,因外再往下就是交由硬件来执行了。
当然机器码也是可以被编辑的,但是以人类难以看懂的姿势存在,可读性非常差。

建立模糊的印象

如果要用一种现实生活中的职业来形容编译器的作用,我想翻译官是一个不错的选择。不论是同声传译,还是各个节目或者动漫的专业字幕组,反正只要能够把 A 语言流畅的翻译成 B 语言的都算。

但翻译的工作并不是那么简单,需要理解某种语言的文字,语法才能进行,当然更专业的人还能使用精简的句子传达意境。总之,这里的 ”翻译“ 其实不仅仅是翻译,还要再经过编辑,这也就就是 “compile“ ,编译的意思。

编译器 Compiler

在有了一个模糊的印象后,我们在聚焦到 compiler 上,compiler 就是计算机编程语言里的翻译官,不同的 compiler 会编译成不同的语言,有可能是转换成机器语言(machine code), byte code, 甚至是另外一种语言,如图:

IMAGE

最终产出的 target program 是能够被直接执行的,所以程序的编译到执行应该是这样的:

IMAGE

这种方式也叫做提前编译,Ahead-Of-Time Compilation(AOT),wiki 传送门:点我

直译器 Interpreter

还有另外一种语言处理的工具:直译器(Interpreter),相较于上图,compiler 是编译 source code 后产出可执行的代码,由使用者输入 input 后,再得到 output。而直译器是 source code 与 input 一起给出,直接编译并执行,产出 output,而使用直译器的语言有耳熟能详的 Python,它的架构如下:

IMAGE

另外 compiler 与 interpreter 在速度上也有一定的差异,compiler 产生的 target program 执行的比 interpreter 快。但 interpreter 的纠错能力又比较好,因为它是一行行的检查与执行程序中的代码。

关于 Interpreter,有的翻译叫做直译器,有的叫做解释器,wiki 传送门:点我)

编译器与直译器的异同

表现 Behavior

  • 编译器把源代码转换成其他的更低级的代码(例如二进制码、机器码),但是不会执行它。
  • 直译器会读取源代码,并且直接生成指令让计算机硬件执行,不会输出另外一种代码。

性能 Performance

  • 编译器会事先用比较多的时间把整个程序的源代码编译成另外一种代码,后者往往较前者更加接近机器码,所以执行的效率会更加高。时间是消耗在预编译的过程中。
  • 直译器会一行一行的读取源代码,解释,然后立即执行。这中间往往使用相对简单的词法分析、语法分析,压缩解释的时间,最后生成机器码,交由硬件执行。直译器适合比较低级的语言。但是相对于预编译好的代码,效率往往会更低。如何减少解释的次数和复杂性,是提高直译器效率的难题。

Compilation + Interpretation

再来,就势必要提及赫赫有名的 Java,为什么呢?Java 是一个结合 Compilation 和 Interpretation 的程序语言,这是什么意思呢?

就是 Java 会先编译成 byte code,接着再直译成机器码,这样的好吃是,Java 经历过一次编译,就可以通过虚拟机(Virtual machine)在不同的机器上直接执行。

沿用文章开始的翻译人员例子,byte code 就像是目前的通用国际语言 - 英语。只要将 A 语言翻译成英文,且 B 国人人能直接把英语翻译成自己的语言(当然前提是大家都会英文),此时,大家的交流就没有任何障碍了,整体的架构如下:

IMAGE

这种方式也叫做即时编译,Just-In-Time Compilation(JIT),wiki 传送门:点我

结合到实际

IMAGE

从左往右看,

  • 以 Java 为例,我们在文本编译器写好了 Java 代码,交由编译器编译成 Java Bytecode。然后 Bytecode 交由 JVM 来执行,这时候 JVM 充当了直译器的角色,在解释 Bytecode 成 Machine Code 的同时执行它,返回结果。

  • 以 BASIC 语言(早期的可以由计算机直译的语言) 为例,通过文本编译器编写好,不用经历编译的过程,就可以直接交由操作系统内部来进行解释然后执行。

  • 以 C 语言为例,我们在文本编译器编写好源代码,然后运行 gcc hello.c 编译出 hello.out 文件,该文件由一系列的机器指令组成的机器码,可以直接交由硬件来执行。

从抽象里看本质

无论是编译 (Compiler),还是直译 (Interpreter),甚至是即时编译。
本质还是人与计算机的交流形式,人的语言最终转换成机器语言。

一句 Hello World,经过一些列的编译和直译,最终转换成一系列包含机器指令的那些 0 和 1,机器傻傻执行完之后,告诉你结果。

就这么一个过程,我们就需要很多的翻译官。
有些翻译官可以做到同声传译(直译),有些翻译官却只能把我们的意图记下来再全部翻译(编译)给计算机。

而往往一个翻译官能力有限,也只能把你的语言,翻译成另外一种低级点的语言,再由另外懂这个语言的翻译官来翻译更接近计算机能读得懂的语言。

总结

这篇文章从一些与编译相关的常见概念说起,通俗的描述了编译原理范畴内的编译器与直译器:

  • 编译 Compile:把整个程序源代码翻译成另外一种代码,然后等待被执行,发生在运行之前,产物是另一份代码。
  • 直译 Interpret:把程序源代码一行一行的读懂然后执行,发生在运行时,产物是运行结果。

同时我们还用一些常见的计算机编程语言作为例子,浅显的解释了它们的编译过程。

希望通过这篇文章,你能对编译在计算机领域里扮演的角色和功能形成一个清晰的认知。

MySQL自治平台建设的内核原理及实践(上)

本文整理自主题分享《美团数据库自治服务平台建设》,系超大规模数据库集群保稳系列的第四篇文章。本文作者在演讲后根据同学们的反馈,补充了很多技术细节,跟演讲(视频)相比,内容更加丰富。文章分成上、下两篇,上篇将介绍数据库的异常发现跟诊断方面的内容,下篇将介绍内核可观测性建设、全量SQL、异常处理以及索引优化建议与SQL治理方面的内容。希望能够对大家有所帮助或启发。

MVVM架构设计在iOS中的实践

在iOS开发中,MVC架构模式下,控制器会过于臃肿,所以目前比较流行的是MVVM架构模式。下面简单介绍一下iOS中MVVM的实践落地。

一、iOS的MVVM

下图是MVVM-C设计模式的结构图,其中的C指的不是控制器,而是作为展示或者关闭控制器的Coordinate(协调器)。在实际开发中,我们一般在Controller中完成展示或者关闭控制器的任务,所以这里我们不关注协调器。 MVVM-C.png

1. 职责划分

相比MVC来说,新增了一个VM, 下面是各个模块的职责:

VMVMVM之间的桥梁, 提供一系列属性用于View的显示,属性包含将Model变形转换为View展示时应有的值。在iOS中通常还会负责网络请求及Model更新。

VC:负责建立VM中属性与View的绑定关系;负责交互事件响应的具体逻辑;如果不建立图中协调器时,通常还包括还包括页面的跳转逻辑。

V: 视图的具体创建和用户交互监听,模型中数据的呈现逻辑。

M:负责存储和管理应用程序所需的数据,以及执行相关的业务逻辑。它不应该与V或者VM或者控制器产生耦合。

2. 响应式编程RxSwift来做绑定

  • 上述提到VC中负责建立绑定关系,我们可以使用KVO来实现,但在有需要数据变形转换时比较麻烦,不推荐;RxSwift是专门用于响应式编程的一套框架,里面提供了很多变形相关的函数,能帮助更好的建立绑定关系。
  • 使用RxSwift,可以根据需要来实现单向或者双向的绑定,当我们熟练RxSwift的函数后,能提升我们的代码质量和便捷性。

二、一个响应式编程例子

下面是使用RxSwift实现的简单绑定:

var modelObject: ModelObject! 
var disposeBag = DisposeBag()

override func viewDidLoad() { 
    super.viewDidLoad() 
    
    modelObject.valueObservable.map { possibleValue -> String in 
        if let value = possibleValue { 
            return "Selected value is: \(value)" 
        } else { 
            return "No value selected" 
        } 
    }.bind(to: self.textLabel.rx.text).disposed(by: disposeBag) 
}

1. 为什么绑定很重要?

如上代码,相比于在很多地方设置 textLabel.text 的值,建立绑定后这个 textLabel 只会在最后被引用一次。响应式编程让我们从目的地---也就是数据的订阅者开始,一路通过数据变形进行回溯,直到到达原始的数据依赖 - 可观察量 (observable)。通过这么做,数据管道的可观察量数据变形以及订阅者三者得以分离。 数据变形的部分是响应式编程所能带来的最大优势,但同时它也是学习曲线最为陡峭的部分。

2. RxSwift中的一些基本类型

  • Observable是一个可观察量,我们可以对它们进行变形,订阅,或者将它们绑定到UI 元素上去。
  • PublishSubject Observable 的一种,我们可以将值通过它来发送,这些值会被发给观察者,最终订阅者收到回调。
  • BehaviorSubjectReplaySubject PublishSubject 类似,不过我们可以在没有任何观察者连接上它时就进行值的发送,有新的观察者时,会接收到之前发送过的暂存在 “重放”缓冲区上的值。
  • Disposable DisposeBag 分别用来控制一个或多个订阅的生命周期。当一个Disposable 被销毁或者手动丢弃时,订阅行为就将结束,另外该订阅的所有的可观察量组成部分也将被释放。

3. RxSwift 中的部分变形函数

  • Map:映射
  • Filter:过滤
  • concat:将两个A、B两个Observable“串行”起来,在A发送onComplete前只会接受A的消息,A发送onComplete后才会接收B的消息。
  • Merge: 合并多个可观察序列,当其中一个发出消息时,会收到订阅回调。
  • taketake(whiletake(until等:控制订阅次数或根据触发条件订阅。
  • flatMapLatest:只保留flatMapLatest返回的最新一个Observable的订阅(flatMapLatest函数返回的是一个Observable)。

更具体的内容可以在RxSwift的网站查看

三、一个双向绑定的例子

大多情况下,我们只需要单向绑定;但有时候可能会需要双向绑定,下面是一个双向绑定的示例: 登录页输入框要与VM的数据双向绑定。

class ViewController: UIViewController {
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var label: UILabel!
    var userVM = UserViewModel()
    let disposeBag = DisposeBag()
 
    override func viewDidLoad() {
        //将用户名与textField做双向绑定
        userVM.username.asObservable().bind(to: textField.rx.text).disposed(by: disposeBag)
        textField.rx.text.orEmpty.bind(to: userVM.username).disposed(by: disposeBag)
         
        //将用户信息绑定到label上
        userVM.userinfo.bind(to: label.rx.text).disposed(by: disposeBag)
    }
}

// 我们可以将双向绑定定义一个为一个操作符(官方demo中有这个文件,可拷贝)
// 上述中双向绑定的代码可以简化为:
//将用户名与textField做双向绑定
_ =  self.textField.rx.textInput <->  self.userVM.username

如果感兴趣的话,可以在RxSwift中的github上的样板工程: https://github.com/ReactiveX/RxSwift

Swift 的可选值优化

本文作者:苯酚

nil 的语义

在 Objective-C 中,nil 表示空对象,它本质是一个指向 0x00000000 的指针。但对于非指针的值类型,OC 中是无法表示_没有值_这个概念的,比如 NSInteger,它可以是 0,也可以是其他任何值,但就是不存在_没有值_。

Swift 作为一种强类型的语言,它从一开始就引入了_没有值_这个概念,虽然还是用 nil 关键字,但实际语义上有所不同。比如 Int?,它可以是 nil,也可以是 00 是一个具体的值,而 nil 不是。然而,计算机作为一个二进制的机器,它内存中保存的非 0 即 1,如何表示_没有值_呢?换句话说,nil 在内存中究竟是什么?我们可以通过简单的代码找出它在内存中的真相。

nil 在内存中的表示

/// 以下方法取 value 的地址,并从地址处向后取它在内存中的大小 size 个字节,转为对应的数组
func bytes<T>(of value: T) -> [UInt8] {
    var value = value
    let size = MemoryLayout<T>.size
    return withUnsafePointer(to: &value, {
        $0.withMemoryRebound(
            to: UInt8.self,
            capacity: size,
            {
                Array(UnsafeBufferPointer(
                    start: $0, count: size))
            })
    })
}

var int: Int? = 0
bytes(of: int)    // [0, 0, 0, 0, 0, 0, 0, 0, 0]
int = nil
bytes(of: int)    // [0, 0, 0, 0, 0, 0, 0, 0, 1]

从上面我们可以得知,可选的 Int? 类型比普通 Int 类型多占一个字节,用来表示是不是 没有值。如果这样的话,在 structclass 中用可选类型岂不是会浪费较多内存空间?因为内存对齐的缘故,多一个字节,就要浪费剩下的 7 字节,比如:

struct N {
    var b: Int? = 2
    var a: Int? = 3
}

var n = N()
bytes(of: n)    // [2, 0, 0, 0, 0, 0, 0, 0, 0, 76, 68, 3, 1, 0, 0, 0, 
                //  3, 0, 0, 0, 0, 0, 0, 0, 0]

以上原本可以用 16 字节表示的结构体,实际上占了 25 字节(考虑结尾处内存对齐,其实占了 32 字节)。我们在实际开发中,可能会在 class 中声明大量的可选字段,如果都这样的话,那内存使用率也太低了,有优化手段吗?

答案是有的,而且 Swift 编译器已经默默帮我们做了。

nil 的优化

Bool

Bool 类型理论上只用 0 1 两个值,一个 bit 即可,但它却占了一整个 byte ,剩下的几个 bit 是可以用来区分是否有值的。

var b: Bool? = false
bytes(of: b) // [0]
b = true
bytes(of: b) // [1]
b = nil
bytes(of: b) // [2]

从以上结果得知,Swift 用 2 表示 Bool? 的_没有值_,所以没有内存浪费。这样也使得 Bool? 不再是两态的开关,而是一个三态的开关。于是经常在代码中看到看起来比较蠢的写法:

var value: Bool?
if value == true {

}

因为一般来说是不建议 Bool 值与 true 判断等的,它本身已经是 Bool 了。而在 Swift 中又用起来是那么自然……

String

String 类型不同于 Int 这种——0 也是合法值,String 的内存值为 0 是可以表示_没有值_的,所以它也没有内存浪费

var s: String? = "abc"
bytes(of: s)  // [97, 98, 99, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 227]
s = ""
bytes(of: s)  // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224]
s = nil
bytes(of: s)  // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

String 在 Swift 中是一个结构体,无论字符串多长,String 变量本身只占 16 字节,短的字符串通过类似 OC 中 Tagged Pointer 的技术直接存在指针中,长的字符串需要指向堆内存地址。

Class

Class 类型同 OC 中的一样,是指针类型,空指针可以表示_没有值_,没有内存浪费。

class MyObject {
    var b: Int? = 2
    var a: Int? = 3
}

var o: MyObject? = .init()
bytes(of: o)  // [160, 142, 188, 2, 0, 96, 0, 0]
o = nil
bytes(of: o)  // [0, 0, 0, 0, 0, 0, 0, 0]

无论 Class 中有多少成员变量,Class 变量本身(即指向它的指针)只占 8 字节(64位系统中)。

Enum

枚举类型一般是有限的,最终总可以找到一个不在枚举范围内的值表示 没有值,也可以没有内存浪费。

enum Edge {
    case left
    case right
    case top
    case bottom
}

var e: Edge? = .left
bytes(of: e)  // [0]
e = .bottom
bytes(of: e)  // [3]
e = nil
bytes(of: e)  // [4],用越界值表示 nil,没有值

当然并不是所有 Enum 类型都能这样,带关联值的就可能不行。

结语

综上所述,Swift 编译器会尽可能地优化可选值的内存占用,日常开发并不需要太多关心,但是部分情况仍要求开发者尽量少使用可选值,如结构体中连续几个可选 Int 的情况,如果 0 也能满足代码逻辑,就使用非可选值,并用 0 初始化它吧!

// 浪费的内存比较可观
struct My {
   var a: Int?
   var b: Int?
   var c: Int?
   var d: Int?
}

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!

关于iOS中无缝桥接技术

前言

相信很多iOS从业者都知道Foundation对象与Core Foundation对象,前者是Objective-C对象,在ARC中会自动管理它们的生命周期,后者是C对象,在ARC中需要开发者手动管理其生命周期,以免造成内存泄漏。两者之间可以通过“无缝桥接”技术互相转换。

一、转换方式

举个例子

// NSObject 转 C obj
NSArray *nsArray = [NSArray new];
CFArrayRef cfArray = (__bridge CFArrayRef)nsArray;

// C obj 转 NSObject
CFStringRef cfStr = CFStringCreateWithCString(NULL, "cString", kCFStringEncodingUTF8);
NSString * nsStr = (__bridge NSString *)cfStr;

上面的代码中,__bridge表示不改变对象的管理权所有者
  例如在第一个例子里,nsArray转换成cfArray对象后,nsArray依然由ARC管理。开发者无需手动管理,即不需要手动释放cfArray
  而在第二个例子里,cfStr转换成nsStr后,cfStr不会转换管理者,仍然需要开发人员管理其生命周期,最后需要调用CFRelease(cfStr);来释放cfStr

日常开发中,我们最常用的就是__bridge桥式转换,但是也有别的桥式转换是指定的,例如__bridge_retained__bridge_transfer

__bridge_retained 是用于在将Foundation对象转换成Core Foundation对象时,进行ARC内存管理权的剥夺,意味着ARC将交出对象的所有权。若是在第一个例子中改成用__bridge_retained,那么开发者需要用完数组之后就要加上 CFRelease(cfArray) 以释放其内存。
  而__bridge_transfer则与之相反,它用于将Core Foundation对象转换成Foundation对象时将管理权交给ARC,开发者无需再关心其内存释放。

这三种转换方式都是 “桥式转换”

二、桥式转换用途

相信很多单纯objc语言开发的程序员在平日的开发中很少会用到桥式转换,那么为什么需要桥式转换呢?什么场景下我们会用到桥式转换呢?
  其实在 Foundation 框架中,objc类所具备的某些功能,是 CoreFoundation 框架中的C语言数据结构所不具备的,反之亦然。举个例子,假如我们在使用objc的字典NSDictionary时,如果我们想key存入的是一个我们自定义的模型对象,那就会出问题,例如下面的代码

image.png

为什么会出现在 -[MyCustomClass copyWithZone:]: unrecognized selector sent to instance 0x6000005a43e0 这个错误呢?这是因为在 Foundation 框架中的字典,其键的内存管理语义为 “拷贝”,而值的语义是却是“保留”,而NextModel并没有遵循NSCopying协议,并实现copyWithZone方法,所以产生了崩溃。但是CoreFoundation 框架中的字典定义是可以自定义其内存管理语义 的,这时候我们就可以先定义CoreFoundation 框架中的字典,然后使用强大的无缝桥接技术,将它转换成 Foundation 框架中的字典,就能解决这个问题了。
  以下就来说一下如何构造CoreFoundation的字典,先来看看苹果的定义文档如下:

image.pngimage.pngimage.png

图片显示文档定义了CoreFoundation的字典CFMutableDictionaryRef的构造函数是

CFMutableDictionaryRef CFDictionaryCreateMutable(CFAllocatorRef allocator,
                                                 CFIndex capacity,
                                                 const CFDictionaryKeyCallBacks *keyCallBacks,
                                                 const CFDictionaryValueCallBacks *valueCallBacks);

其中,allocator表示内存分配器,负责分配和回收这些 CoreFoundation对象里的数据结构占用的内存,一般传NULL表示使用默认的分配器。
capacity声明字典的初始大小,熟悉C语言的开发都知道这只是初始默认创建的大小,只是向分配器提示了一开始应该分配多少内存,后面会根据它的数据插入而增大容量。
CFDictionaryKeyCallBacksCFDictionaryValueCallBacks是两个指向结构体的指针,其构造如下图

image.png

image.png

除了version表示版本号(目前都是填0),其他都是函数指针,它们定义了当各种事件发生时应该采用哪个函数来执行相关任务。关键的是需要把CFDictionaryKeyCallBacks从copy改成retain,于是我仿写了一下代码如下:

//调用CFRetain函数来增加键的引用计数,并返回键。这将导致CFDictionary不会复制键,而是保留键的引用计数。
const void* myRetainCallback(CFAllocatorRef allocator, const void *value) {
       return CFRetain(value);
}


void myReleaseCallback(CFAllocatorRef allocator, const void *value) {
    CFRelease(value);
}

@interface ViewController ()
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.

    CFDictionaryKeyCallBacks keyCallbacks = {
        0,
        myRetainCallback,
        myReleaseCallback,
        NULL,
        CFEqual,
        CFHash
    };

    CFDictionaryValueCallBacks valueCallbacks = {
        0,
        myRetainCallback,
        myReleaseCallback,
        NULL,
        CFEqual
    };

    //创建CoreFoundation的字典aCFDictionary
    CFMutableDictionaryRef aCFDictionary = CFDictionaryCreateMutable(NULL, 0, &keyCallbacks, &valueCallbacks);
    //将CoreFoundation的字典aCFDictionary转换成Foundation的字典anNSDictionary
    NSMutableDictionary *anNSDictionary = (__bridge_transfer NSMutableDictionary *)aCFDictionary;

    static MyCustomClass *customKey = NULL;//防止被释放
    customKey = [[MyCustomClass alloc] init];
    [anNSDictionary setObject:@"" forKey:customKey];

}

上面这段代码里,CFDictionaryKeyCallBacks引用我自定义的myRetainCallback来管理键的引用计数,从而来避免要求 MyCustomClass 实现 NSCopying 协议。但是需要注意的这种情况下是键是可变的,在修改键时可能会更改键的值,从而使键在字典中的位置变得不确定,因此必须小心使用这种方法。其实最好还是建议使用不可变键,或者在修改键时使用自定义的回调函数来确保字典中的键始终保持不变。

三、后续

当我以为这样已经结束的时候,我把我的代码运行了一下,结果还是报了 -[MyCustomClass copyWithZone:]: unrecognized selector sent to instance 0x600003cd42b0 的错误,无论怎么改都无补于事,难道网上的资料是错误的吗?我尝试询问ChatGpt,得到以下的回答

image.png 我怀疑是objc经过多个版本的迭代和更新,在NSDictionay运行到setObject:forkey:时,并不会预先检查其运行管理语义,而且直接自动寻找这个key类里面的copyWithZone方法,找不到则直接抛出异常,导致运行失败。

四、解决

今天看了一下官方文档,发现NSMutableDictionary是这么定义的 image.pngsetObject:forkey:的aKey需要遵循NSCopying协议,这肯定会检查到copyWithZone方法的,这与我之前的猜想基本符合,那换个思路,假如我先把数据塞进aCFDictionary,再将aCFDictionary转成anNSDictionary,然后再取出来是否可以呢?先去看看NSMutableDictionary取数据的key是不是也要遵循NSCopying协议

image.png 从定义看得出来,KeyType并没有一定要遵循NSCopying协议,于是我修改了一下我的写法,如下

    CFMutableDictionaryRef aCFDictionary = CFDictionaryCreateMutable(NULL, 0, &keyCallbacks, &valueCallbacks);
    static MyCustomClass *customKey = NULL;//防止被释放
    customKey = [[MyCustomClass alloc] init];
    //先塞进去aCFDictionary
    CFDictionarySetValue(aCFDictionary, (__bridge const void *)customKey, (__bridge const void *) @"123");
    //不用这个方案
   //[anNSDictionary setObject:@"" forKey:customKey];

   //看看能不能从aCFDictionary取出来
    NSString *val = (__bridge  NSString *)CFDictionaryGetValue(aCFDictionary, (__bridge const void *)customKey);
    NSLog(@"aCFDictionary:%@", val);

   //再转成anNSDictionary
    NSMutableDictionary *anNSDictionary = (__bridge_transfer NSMutableDictionary *)aCFDictionary;
    NSLog(@"==> %@", anNSDictionary);
    //看看能不能从anNSDictionary读出来
    NSLog(@"%@", [anNSDictionary objectForKey:customKey]);

运行一下,成功了! image.png

五、总结

经过反复的查文档,和同事分享经验,换了个思路终于得出解决的方法,感觉还是需要不断学习,业精于勤而荒于嬉,欢迎各路大神和我分享讨论,感谢!

iOS开发中的离屏渲染

离屏渲染的定义

在显示屏上显示内容,需要一块与屏幕像素数据量一样大的frame buffer来作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。

image.png

CPU”离屏渲染“

如果我们在UIView中实现了drawRect方法,就算它的函数体内部实际没有代码,系统也会为这个view申请一块内存区域,等待CoreGraphics可能的绘画操作。

对于类似这种“新开一块CGContext来画图“的操作,有很多文章和视频也称之为“离屏渲染”(因为像素数据是暂时存入了CGContext,而不是直接到了frame buffer)。进一步来说,其实所有CPU进行的光栅化操作(如文字渲染、图片解码),都无法直接绘制到由GPU掌管的frame buffer,只能暂时先放在另一块内存之中,说起来都属于“离屏渲染”。 因为CPU不擅长做渲染,所以我们需要尽量避免它,就误以为这就是需要避免离屏渲染的原因。但是根据苹果工程师的说法,CPU渲染并非真正意义上的离屏渲染。另一个证据是,如果你的view实现了drawRect,此时打开Xcode调试的“Color offscreen rendered yellow”开关,你会发现这片区域不会被标记为黄色,说明Xcode并不认为这属于离屏渲染。

其实通过CPU渲染就是俗称的“软件渲染”,而真正的离屏渲染发生在GPU。

GPU离屏渲染

在iOS的渲染过程中,主要的渲染操作都是由CoreAnimation的Render Server模块通过调用显卡驱动所提供的OpenGL/Metal接口来执行的。通常对于每一层layer,Render Server会遵循“画家算法”,按次序输出到frame buffer,后一层覆盖前一层,就能得到最终的显示结果;

image.png

然而有些场景并没有那么简单。作为“画家”的GPU虽然可以一层一层往画布上进行输出,但是无法在某一层渲染完成之后,再回过头来改变其中的某个部分——因为在这一层之前的若干层layer像素数据,已经在渲染中被永久覆盖了。这就意味着,对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域来完成一些更复杂的、多次的修改/剪裁操作。

如果要绘制一个带有圆角并剪切圆角以外内容的容器,就会触发离屏渲染。我的猜想是(如果读者中有图形学专家希望能指正):

  • 将一个layer的内容裁剪成圆角,可能不存在一次遍历就能完成的方法。
  • 容器的子layer因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪

此时我们就不得不开辟一块独立于frame buffer的空白内存,先把容器以及其所有子layer依次画好,然后把四个角“剪”成圆形,再把结果画到frame buffer中。这就是GPU的离屏渲染。

常见离屏渲染场景分析

1,cornerRadius+clipsToBounds,原因就如同上面提到的,不得已只能另开一块内存来操作。而如果只是设置cornerRadius(如不需要剪切内容,只需要一个带圆角的边框),或者只是需要裁掉矩形区域以外的内容(虽然也是剪切,但是稍微想一下就可以发现,对于纯矩形而言,实现这个算法似乎并不需要另开内存),并不会触发离屏渲染。关于剪切圆角的性能优化,根据场景不同有几个方案可供选择,非常推荐阅读AsyncDisplayKit中的一篇文档

2,shadow,其原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢?这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到frame buffer,最后把内容画上去(这只是我的猜测,实际情况可能更复杂)。不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。

image.png

3,group opacity,其实从名字就可以猜到,alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。将一对蓝色和红色layer叠在一起,然后在父layer上设置opacity=0.5,并复制一份在旁边作对比。左边关闭group opacity,右边保持默认(从iOS7开始,如果没有显式指定,group opacity会默认打开),然后打开offscreen rendering的调试,我们会发现右边的那一组确实是离屏渲染了。

image.png

4,mask,我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。

GPU离屏渲染的性能影响

GPU的操作是高度流水线化的。本来所有计算工作都在有条不紊地正在向frame buffer输出,此时突然收到指令,需要输出到另一块内存,那么流水线中正在进行的一切都不得不被丢弃,切换到只能服务于我们当前的“切圆角”操作。等到完成以后再次清空,再回到向frame buffer输出的正常流程。

在tableView或者collectionView中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,上面提到的上下文切换就会每秒发生60次,并且很可能每一帧有几十张的图片要求这么做,对于GPU的性能冲击可想而知(GPU非常擅长大规模并行计算,但是我想频繁的上下文切换显然不在其设计考量之中)

善用离屏渲染

尽管离屏渲染开销很大,但是当我们无法避免它的时候,可以想办法把性能影响降到最低。优化思路也很简单:既然已经花了不少精力把图片裁出了圆角,如果我能把结果缓存下来,那么下一帧渲染就可以复用这个成果,不需要再重新画一遍了。

CALayer为这个方案提供了对应的解法:shouldRasterize。一旦被设置为true,Render Server就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。有几个需要注意的点:

  • shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,也没有圆角阴影等等,打开这个开关反而会增加一次不必要的离屏渲染;
  • 离屏渲染缓存有空间上限,最多不超过屏幕总像素的2.5倍大小;
  • 一旦像素缓存的时间超过100ms没有被使用,会自动被丢弃;
  • layer的内容(包括子layer)必须是静态的,因为一旦发生变化(如resize,动画),之前辛苦处理得到的缓存就失效了。如果这件事频繁发生,我们就又回到了“每一帧都需要离屏渲染”的情景,而这正是开发者需要极力避免的。针对这种情况,Xcode提供了“Color Hits Green and Misses Red”的选项,帮助我们查看缓存的使用是否符合预期
  • 其实除了解决多次离屏渲染的开销,shouldRasterize在另一个场景中也可以使用:如果layer的子结构非常复杂,渲染一次所需时间较长,同样可以打开这个开关,把layer绘制到一块缓存,然后在接下来复用这个结果,这样就不需要每次都重新绘制整个layer树了

什么时候需要CPU渲染

渲染性能的调优,其实始终是在做一件事:平衡CPU和GPU的负载,让他们尽量做各自最擅长的工作。

绝大多数情况下,得益于GPU针对图形处理的优化,我们都会倾向于让GPU来完成渲染任务,而给CPU留出足够时间处理各种各样复杂的App逻辑。为此Core Animation做了大量的工作,尽量把渲染工作转换成适合GPU处理的形式(也就是所谓的硬件加速,如layer composition,设置backgroundColor等等)。

但是对于一些情况,如文字(CoreText使用CoreGraphics渲染)和图片(ImageIO)渲染,由于GPU并不擅长做这些工作,不得不先由CPU来处理好以后,再把结果作为texture传给GPU。除此以外,有时候也会遇到GPU实在忙不过来的情况,而CPU相对空闲(GPU瓶颈),这时可以让CPU分担一部分工作,提高整体效率。

一个典型的例子是,我们经常会使用CoreGraphics给图片加上圆角(将图片中圆角以外的部分渲染成透明)。整个过程全部是由CPU完成的。这样一来既然我们已经得到了想要的效果,就不需要再另外给图片容器设置cornerRadius。另一个好处是,我们可以灵活地控制裁剪和缓存的时机,巧妙避开CPU和GPU最繁忙的时段,达到平滑性能波动的目的。

这里有几个需要注意的点:

  • 渲染不是CPU的强项,调用CoreGraphics会消耗其相当一部分计算时间,并且我们也不愿意因此阻塞用户操作,因此一般来说CPU渲染都在后台线程完成(这也是AsyncDisplayKit的主要思想),然后再回到主线程上,把渲染结果传回CoreAnimation。这样一来,多线程间数据同步会增加一定的复杂度
  • 同样因为CPU渲染速度不够快,因此只适合渲染静态的元素,如文字、图片
  • 作为渲染结果的bitmap数据量较大(形式上一般为解码后的UIImage),消耗内存较多,所以应该在使用完及时释放,并在需要的时候重新生成,否则很容易导致OOM
  • 如果你选择使用CPU来做渲染,那么就没有理由再触发GPU的离屏渲染了,否则会同时存在两块内容相同的内存,而且CPU和GPU都会比较辛苦
  • 一定要使用Instruments的不同工具来测试性能,而不是仅凭猜测来做决定

即刻的优化

由于在iOS10之后,系统的设计风格慢慢从扁平化转变成圆角卡片,即刻的设计风格也随之发生变化,加入了大量圆角与阴影效果,如果在处理上稍有不慎,就很容易触发离屏渲染。为此我们采取了以下一些措施:

  • 即刻大量应用AsyncDisplayKit(Texture)作为主要渲染框架,对于文字和图片的异步渲染操作交由框架来处理。关于这方面可以看我之前的一些介绍
  • 对于图片的圆角,统一采用“precomposite”的策略,也就是不经由容器来做剪切,而是预先使用CoreGraphics为图片裁剪圆角
  • 对于视频的圆角,由于实时剪切非常消耗性能,我们会创建四个白色弧形的layer盖住四个角,从视觉上制造圆角的效果
  • 对于view的圆形边框,如果没有backgroundColor,可以放心使用cornerRadius来做
  • 对于所有的阴影,使用shadowPath来规避离屏渲染
  • 对于特殊形状的view,使用layer mask并打开shouldRasterize来对渲染结果进行缓存
  • 对于模糊效果,不采用系统提供的UIVisualEffect,而是另外实现模糊效果(CIGaussianBlur),并手动管理渲染结果
  • 总结:

离屏渲染牵涉了很多Core Animation、GPU和图形学等等方面的知识,在实践中也非常考验一个工程师排查问题的基本功、经验和判断能力——如果在不恰当的时候打开了shouldRasterize,只会弄巧成拙。

百度APP iOS端包体积50M优化实践(三) 资源优化

01 前言

百度APP iOS端包体积优化系列文章的前两篇重点介绍了包体积优化整体方案、各项优化收益和图片优化方案,图片优化是从无用图片、Asset Catalog和HEIC格式三个角度做深度优化。本文重点介绍资源优化,在百度APP实践中,资源优化包括大资源优化、无用配置文件和重复资源优化。不管是资源优化还是代码优化,都需要分析Mach-O文件,以获取资源和代码的引用关系,本文先详细介绍Mach-O文件。

百度APP iOS端包体积优化实践系列文章回顾:

百度APP iOS端包体积50M优化实践(一)总览

百度APP iOS端包体积50M优化实践(二) 图片优化

02 Mach-O文件详解

2.1 简介

Mach-O为Mach Object文件格式的缩写,用于记录可执行文件、目标代码、动态库和内存转储的文件格式,是运用于Mac以及iOS系统上。

2.2 分析Mach-O文件的工具

2.2.1 MachOView分析

用MachOView能查看MachO文件信息,启动MachOView,在状态栏中点击file,打开MachO文件,如下图所示。

图片

2.2.2 otool命令查看

mac自带otool工具,otool -arch arm64 -ov xxx.app/xxx,可获取所有项目的类结构及定义的方法,示例代码如下所示:

Contents of (__DATA,__objc_classlist) section
0000000100008238 0x100009980
isa        0x1000099a8
superclass 0x0 _OBJC_CLASS_$_UIViewController
cache      0x0 __objc_empty_cache
vtable     0x0
data       0x1000083e8
flags          0x90
instanceStart  8
instanceSize   8
reserved       0x0
ivarLayout     0x0
name           0x100007349 ViewController
baseMethods    0x1000082d8
entsize 24
count   11
name    0x100006424 test4
types   0x1000073e4 v16@0:8
imp     0x100004c58
name    0x1000063b4 viewDidLoad
*****

下面列举otool常见命令:

图片

2.3 查看文件格式

采用file命令可以查看文件格式,lipo -info可查看该Mach-O文件支持的具体CPU架构。

~ % file /Users/ycx/Desktop/demo.app/demo
/Users/ycx/Desktop/demo.app/demo: Mach-O 64-bit executable arm64
~ % lipo -info /Users/ycx/Desktop/demo.app/demo
Non-fat file: /Users/ycx/Desktop/demo.app/demo is architecture: arm64

2.4 文件结构

2.4.1 总体结构

图片

Mach-O文件主要由三部分组成Header、LoadCommands、Data,在MachO文件的末尾,还有Loader Info信息,表示可执行文件依赖的字符串表,符号表等信息。

2.4.2 Header(头部)

2.4.2.1 数据结构

Header(头部): 用于描述当前Mach-O文件的基本信息(CPU类型、文件类型等),XNU代码路径:EXTERNAL_HEADERS/mach-o/loader.h,数据结构如下所示:

struct mach_header_64 {
  uint32_t  magic;    /* mach magic number identifier */
  cpu_type_t  cputype;  /* cpu specifier */
  cpu_subtype_t  cpusubtype;  /* machine specifier */
  uint32_t  filetype;  /* type of file */
  uint32_t  ncmds;    /* number of load commands */
  uint32_t  sizeofcmds;  /* the size of all the load commands */
  uint32_t  flags;    /* flags */
  uint32_t  reserved;  /* reserved */
};

2.4.2.2 查看字段值

命令otool -hv可查看Header每个字段值。

% otool -hv demo
demo:
Mach header
      magic  cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64    ARM64        ALL  0x00     EXECUTE    22       3040   NOUNDEFS DYLDLINK TWOLEVEL PIE

用MachOView查看Header数据值:

图片

2.4.2.3 字段具体含义

各个字段具体含义如下所示:

图片

2.4.3 LoadCommands(加载命令)

2.4.3.1 数据结构

LoadCommands(加载命令): 用于描述文件的组织架构和在虚拟内存中的布局方式,告诉操作系统如何加载Mach-O文件中的数据。XNU代码路径:EXTERNAL_HEADERS/mach-o/loader.h,数据结构如下所示,其中cmd代表加载命令类型,cmdsize代表加载命令大小,在load_command数据结构后面加一个特定结构体信息,不同的cmd类型,结构体也不同。

struct load_command {
  uint32_t cmd;    /* type of load command */
  uint32_t cmdsize;  /* total size of command in bytes */
};
/* Constants for the cmd field of all load commands, the type */
#define  LC_SEGMENT  0x1  /* segment of this file to be mapped */
#define  LC_SYMTAB  0x2  /* link-edit stab symbol table info */
#define  LC_SYMSEG  0x3  /* link-edit gdb symbol table info (obsolete) */
#define  LC_THREAD  0x4  /* thread */
#define  LC_UNIXTHREAD  0x5  /* unix thread (includes a stack) */
#define  LC_LOADFVMLIB  0x6  /* load a specified fixed VM shared library */
#define  LC_IDFVMLIB  0x7  /* fixed VM shared library identification */
#define  LC_IDENT  0x8  /* object identification info (obsolete) */
#define LC_FVMFILE  0x9  /* fixed VM file inclusion (internal use) */
#define LC_PREPAGE      0xa     /* prepage command (internal use) */
#define  LC_DYSYMTAB  0xb  /* dynamic link-edit symbol table info */
#define  LC_LOAD_DYLIB  0xc  /* load a dynamically linked shared library */
#define  LC_ID_DYLIB  0xd  /* dynamically linked shared lib ident */
#define LC_LOAD_DYLINKER 0xe  /* load a dynamic linker */
#define LC_ID_DYLINKER  0xf  /* dynamic linker identification */
#define  LC_PREBOUND_DYLIB 0x10  /* modules prebound for a dynamically */
*****

2.4.3.2 查看字段值

用otool -lv命令可以看到该字段全部信息,如左下图所示,此外,我们也可用MachOView工具可更直观地观察具体字段,如右下图所示。

图片

2.4.3.3 cmd类型及其具体作用

常见的cmd类型及其具体作用如下面表格所示:

图片

2.4.3.4 LC_SEGMENT_64

2.4.3.4.1 数据结构

在众多cmd命令中,我们需要重点关注的是LC_SEGMENT/LC_SEGMENT_64,LC_SEGMENT是32位,LC_SEGMENT_64是64位,目前主流机型是LC_SEGMENT_64。LC_SEGMENT_64作用是如何将Data中的各个Segment加载入内存中,而和我们APP相关的代码及数据,大部分位于各个Segment中。其数据结构名称是segment_command_64,XNU代码路径:EXTERNAL_HEADERS/mach-o/loader.h,源码如下所示:

struct segment_command_64 { /* for 64-bit architectures */
  uint32_t  cmd;    /* LC_SEGMENT_64 */
  uint32_t  cmdsize;  /* includes sizeof section_64 structs */
  char    segname[16];  /* segment name */
  uint64_t  vmaddr;    /* memory address of this segment */
  uint64_t  vmsize;    /* memory size of this segment */
  uint64_t  fileoff;  /* file offset of this segment */
  uint64_t  filesize;  /* amount to map from the file */
  vm_prot_t  maxprot;  /* maximum VM protection */
  vm_prot_t  initprot;  /* initial VM protection */
  uint32_t  nsects;    /* number of sections in segment */
  uint32_t  flags;    /* flags */
};

图片

Mach-O文件有多个段(Segment),每个段有不同的功能,每个段又按不同功能划分为多个区(section),四个Segment为__PAGEZERO、__TEXT、_DATA和_LINKEDIT,下面详细介绍。

2.4.3.4.2 _PAGEZERO

图片

__PAGEZERO Segment是空指针陷阱段,主要是用来捕捉NULL指针的引用,是Mach内核虚拟出来的,是Mach-O加载进内存之后附加的一块区域,maxprot和initprot值都为VM_PROT_NONE,表示它不可读,不可写,如果访问__PAGEZERO段,会引起程序崩溃。从上图可以发现,VM Size是4GB,但是真实的File Size大小是0,它只是一个逻辑上的段,在Data中,根本没有对应的内容,也没有占用任何硬盘空间。

2.4.3.4.3 _TEXT

图片

__TEXT Segment对应的就是代码段,下图是一张示例截图,其有11个Section,该段对应的内容加载到内存的过程是:从File Offset开始加载大小为File Size的文件,从虚拟地址VM Address开始装填,大小也是VM Size,VM Size跟文件大小File Size是相同的,我们发现其File Offset为0,在Mach-O文件布局中,__TEXT类型的Segment前面有_PAGEZERO类型的Segment,但_PAGEZERO段的File Offse和File Size为0,所以__TEXT段的File Offset为0。

maxprot和initprot值都为VM_PROT_READ和VM_PROT_EXECUTE,代码段权限是只读和可执行,防止在内存中被修改。

2.4.3.4.4 _DATA

图片

__DATA Segment对应的就是数据段,maxprot和initprot值都为VM_PROT_READ和VM_PROT_WRITE,数据段权限是可读和可写。

2.4.3.4.5 _LINKEDIT

图片

__LINKEDIT Segment用于描述链接信息段,指向存放 link 操作必要的数据段。

2.4.4 Data(数据段)

图片

Mach-O的Data部分,其实是真正存储APP二进制数据的地方,前面的header和load command,仅是提供文件的说明以及加载信息的功能。

Data(数据段): 主要是代码、数据,包含了Load commands中需要的各个段(Segment)的数据,每个Segment可以有多个Section,下面列举一些常见的 Section。在Data(数据段)中,大写的字符串(如__TEXT)代表的是Segment,小写的字符串(如__objc_methtype)代表的是Section。

图片

03 资源优化

3.1 简介

作为一个航母级别的APP,百度APP技术栈丰富多样,市面上常见的技术框架都有使用,如Hybrid框架、小程序框架、React Native框架、KMM和端智能。此外,百度APP作为日活过亿的APP,为满足用户复杂多变的需求,具有的功能包罗万象,如搜索、Feed、短视频、直播、购物、小说、地图、网盘、美颜、人脸识别、AR库等,导致内置的大块资源(大于40K)就有26M,具有很大的优化空间,资源优化分为三个部分,分别是大资源优化、无用配置文件和重复资源优化,本章节接下来详细介绍各个模块的优化方案。

3.2 大资源优化

3.2.1 获取大资源

资源是指plist、js、css、json、端智能模型文件等,因这些文件和图片在优化方式差异很大,所以把两者区分开来。获取大资源主要途径是递归遍历ipa包的所有资源,体积大于指定阈值的文件就是我们要针对性优化的大资源,在百度APP优化实践中我们选取了40K作为阈值,参考脚本如下所示:

def findBigResources(path,threshold):
    pathDir = os.listdir(path)
    for allDir in pathDir:
        child = os.path.join('%s%s' % (path, allDir))
        if os.path.isfile(child):
            # 获取读到的文件的后缀
            end = os.path.splitext(child)[-1]
            # 过滤掉dylib系统库和asset.car
            if end != ".dylib" and end != ".car":
                temp = os.path.getsize(child)
                # 转换单位:B -> KB
                fileLen = temp / 1024
                if fileLen > threshold:
                    #print(end)
                    print(child + " length is " + str(fileLen));
        else:
            # 递归遍历子目录
            child = child + "/"
            findBigResources(child,threshold)

3.2.2 优化方法

  • 异步下载:只要APP首次启动时不需要加载该资源,或者即使首次启动需要加载但是使用频率不高,那么该资源就可以走异步下载;

  • 资源压缩:当APP首次启动需要加载且频率较高的情况下,可以对大块资源先进行压缩内置APP,启动阶段异步线程解压再使用;

3.2 无用的配置文件

3.3.1 获取配置文件

从ipa包中获取plist、json、txt、xib等配置文件,百度技术方案采用的是排除法,因为实践中发现配置文件格式千奇百怪,很多业务模块出于安全考虑自定义各种后缀文件,无法穷举,所以采用了排除法。针对图片资源我们有专门的优化方法,所以首先将png、webp、gif、jpg排除掉,JS&CSS资源是一般HTML加载的,在mach-o文件中TEXT字段静态字符串常量不会有体现,所以也需要排除掉,最后获取到的就是我们需要的配置文件,参考脚本如下所示:

def findProfileResources(path):
    pathDir = os.listdir(path)
    for allDir in pathDir:
        child = os.path.join('%s%s' % (path, allDir))
        if os.path.isfile(child):
            # 获取读到的文件的后缀
            end = os.path.splitext(child)[-1]
            if end != ".dylib" and end != ".car" and end != ".png" and end != ".webp" and end != ".gif" and end != ".js" and end != ".css":
                print(child + " 后缀 " + end)
        else:
            # 递归遍历子目录
            child = child + "/"
            findProfileResources(child)

3.3.2 mach-o文件获取静态字符串常量

我们加载配置文件的代码经过编译链接最后都会以字符串形式存储到mach-o文件中,具体是TEXT字段静态字符串常量__cstring中,用otool命令可以获取,参考脚本如下所示:

 lines = os.popen('/usr/bin/otool -v -s __TEXT __cstring %s' % path).readlines()

3.3.3 获取无用配置文件

前面获取的集合做diff,获取无用配置文件,确认无误后删除以减少包体积。如果你的资源名是拼接使用的,就无法命中,所以删除资源一定要逐个确认。

3.3.4 JS&CSS无用文件排查

JS&CSS文件具有特殊性,OC代码可以引用,HTML文件也可以加载引用,图片也是这种情况,但是上面提到的mach-o文件中TEXT字段只能覆盖OC文件的引用方式,而HTML加载才是主流场景,为此针对这种case百度APP采用跟无用图片检测类似的解决方案。

3.4 重复资源优化

从iPA包中获取所有资源文件,通过MD5判断资源是否重复,参考脚本如下所示:

def get_file_library(path, file_dict):
    pathDir = os.listdir(path)
    for allDir in pathDir:
        child = os.path.join('%s/%s' % (path, allDir))
        if os.path.isfile(child):
            md5 = img_to_md5(child)
            # 将md5存入字典
            key = md5
            file_dict.setdefault(key, []).append(allDir)
            continue
        get_file_library(child, file_dict)

def img_to_md5(path):
    fd = open(path, 'rb')
    fmd5 = hashlib.md5(fd.read()).hexdigest()
    fd.close()
    return fmd5

04 总结

资源优化是包体积优化的重头戏,优化的过程中影响面可控,所以落地收益比较容易,百度APP经过两个季度的优化落地12M的收益,基本解决存量资源的优化问题,同时建立资源使用规范和相应的检测流水线解决增量问题。

本文对Mach-O文件格式做了系统阐释,并且详细介绍了百度APP大资源优化、无用配置文件和重复资源优化方案,后续我们会针对其他优化详细介绍其原理与实现,敬请期待。

—— END——

参考资料:

[1]、Mach内核介绍:https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/Mach/Mach.html

[2]、《深入解析Mac OS X & iOS操作系统》

[3]、XNU源码:https://github.com/apple/darwin-xnu

[4]、Mach-O介绍:https://alexdremov.me/mystery-of-mach-o-object-file-builders/

[5]、初识Mach-O文件:https://www.jianshu.com/p/81928c705c88

推荐阅读:

代码级质量技术之基本框架介绍

基于openfaas托管脚本的实践

百度工程师移动开发避坑指南——Swift语言篇

百度工程师移动开发避坑指南——内存泄漏篇

增强型语言模型——走向通用智能的道路?

基于公共信箱的全量消息实现

PAG动效框架源码笔记 (四)渲染框架

转载请注明出处:http://www.olinone.com/

前言

PAG采用自研TGFX特效渲染引擎,抽象分离了接口及平台实现类,可以扩展支持多种图形渲染库,比如OpenGL、Metal等

TGFX引擎是如何实现纹理绘制?本文基于OpenGL图形库分析讲解TGFX渲染框架分层及详细架构设计。开始之前,先提一个问题:

绘制一个Texture纹理对象,一般需要经历哪些过程?

渲染流程

通常情况下,绘制一个Texture纹理对象到目标Layer上,可以抽象为以下几个阶段:

1. 获取上下文: 通过EGL获取Context绘制上下文,提供与渲染设备交互的能力,比如缓冲区交换、Canvas及Paint交互等

2. 定义着色器: 基于OpenGL的着色器语言(GLSL)编写着色器代码,编写自定义顶点着色器和片段着色器代码,编译、链接加载和使用它们

3. 绑定数据源: 基于渲染坐标系几何计算绑定顶点数据,加载并绑定纹理对象给GPU,设置渲染目标、混合模式等

4. 渲染执行: 提交渲染命令给渲染线程,转化为底层图形API调用、并执行实际的渲染操作

img

关于OpenGL完整的渲染流程,网上有比较多的资料介绍,在此不再赘述,有兴趣的同学可以参考 OpenGL ES Pipeline

框架层级

TGFX框架大致可分为三大块:

1. Drawable上下文: 基于EGL创建OpenGL上下文,提供与渲染设备交互的能力

2. Canvas接口: 定义画布Canvas及画笔Paint,对外提供渲染接口、记录渲染状态以及创建绘制任务等

3. DrawOp执行: 定义并装载着色器函数,绑定数据源,执行实际渲染操作

为了支持多平台,TGFX定义了一套完整的框架基类,实现框架与平台的物理隔离,比如矩阵对象Matrix、坐标Rect等,应用上层负责平台对象与TFGX对象的映射转化

- (void)setMatrix:(CGAffineTransform)value {
  pag::Matrix matrix = {};
  matrix.setAffine(value.a, value.b, value.c, value.d, value.tx, value.ty);
  _pagLayer->setMatrix(matrix);
}

Drawable上下文

PAG通过抽象Drawable对象,封装了绘制所需的上下文,其主要包括以下几个对象

1. Device(设备): 作为硬件设备层,负责与渲染设备交互,比如创建维护EAGLContext等

2. Window(窗口): 拥有一个Surface,负责图形库与绘制目标的绑定,比如将的opengl的renderBuffer绑定到CAEAGLLayer上;

3. Surface(表面): 创建canvas画布提供可绘制区域,对外提供flush绘制接口;当窗口尺寸发生变化时,surface会创建新的canvas

4. Canvas(画布): 作为实际可绘制区域,提供绘制api,进行实际的绘图操作,比如绘制一个image或者shape等

详细代码如下:

1、Device创建Context
std::shared_ptr<GLDevice> GLDevice::Make(void* sharedContext) {
  if (eaglShareContext != nil) {
    eaglContext = [[EAGLContext alloc] initWithAPI:[eaglShareContext API]
                                        sharegroup:[eaglShareContext sharegroup]];
  } else {
    // 创建Context
    eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
    if (eaglContext == nil) {
      eaglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
    }
  }
  auto device = EAGLDevice::Wrap(eaglContext, false);
  return device;
}

std::shared_ptr<EAGLDevice> EAGLDevice::Wrap(EAGLContext* eaglContext, bool isAdopted) {
  auto oldEAGLContext = [[EAGLContext currentContext] retain];
  if (oldEAGLContext != eaglContext) {
    auto result = [EAGLContext setCurrentContext:eaglContext];
    if (!result) {
      return nullptr;
    }
  }
  auto device = std::shared_ptr<EAGLDevice>(new EAGLDevice(eaglContext),
                                            EAGLDevice::NotifyReferenceReachedZero);
  if (oldEAGLContext != eaglContext) {
    [EAGLContext setCurrentContext:oldEAGLContext];
  }
  return device;
}

// 获取Context
bool EAGLDevice::makeCurrent(bool force) {
  oldContext = [[EAGLContext currentContext] retain];
  if (oldContext == _eaglContext) {
    return true;
  }
  if (![EAGLContext setCurrentContext:_eaglContext]) {
    oldContext = nil;
    return false;
  }
  return true;
}
2、Window创建Surface,绑定RenderBuffer
std::shared_ptr<Surface> EAGLWindow::onCreateSurface(Context* context) {
  auto gl = GLFunctions::Get(context);
  ...
  gl->genFramebuffers(1, &frameBufferID);
  gl->bindFramebuffer(GL_FRAMEBUFFER, frameBufferID);
  gl->genRenderbuffers(1, &colorBuffer);
  gl->bindRenderbuffer(GL_RENDERBUFFER, colorBuffer);
  gl->framebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, colorBuffer);
  auto eaglContext = static_cast<EAGLDevice*>(context->device())->eaglContext();
  // 绑定到CAEAGLLayer上
  [eaglContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:layer];
  ...
  GLFrameBufferInfo glInfo = {};
  glInfo.id = frameBufferID;
  glInfo.format = GL_RGBA8;
  BackendRenderTarget renderTarget = {glInfo, static_cast<int>(width), static_cast<int>(height)};
  // 创建Surface
  return Surface::MakeFrom(context, renderTarget, ImageOrigin::BottomLeft);
}

// 通过renderTarget持有context、frameBufferID及Size
std::shared_ptr<Surface> Surface::MakeFrom(Context* context,
                                           const BackendRenderTarget& renderTarget,
                                           ImageOrigin origin, const SurfaceOptions* options) {
  auto rt = RenderTarget::MakeFrom(context, renderTarget, origin);
  return MakeFrom(std::move(rt), options);
}
3、Surface创建Canvas及flush绘制
Canvas* Surface::getCanvas() {
  // 尺寸变化时会清空并重新创建canvas
  if (canvas == nullptr) {
    canvas = new Canvas(this);
  }
  return canvas;
}

bool Surface::flush(BackendSemaphore* signalSemaphore) {
  auto semaphore = Semaphore::Wrap(signalSemaphore);
  // drawingManager创建tasks,装载绘制pipiline
  renderTarget->getContext()->drawingManager()->newTextureResolveRenderTask(this);
  auto result = renderTarget->getContext()->drawingManager()->flush(semaphore.get());
  return result;
}
4、渲染流程
bool PAGSurface::draw(RenderCache* cache, std::shared_ptr<Graphic> graphic,
                      BackendSemaphore* signalSemaphore, bool autoClear) {
  // 获取context上下文                    
  auto context = lockContext(true);
  // 获取surface
  auto surface = drawable->getSurface(context);
  // 通过canvas画布
  auto canvas = surface->getCanvas();
  // 执行实际绘制
  onDraw(graphic, surface, cache);
  // 调用flush
  surface->flush();
  // glfinish
  context->submit();
  // 绑定GL_RENDERBUFFER
  drawable->present(context);
  // 释放context上下文
  unlockContext();
  return true;
}

Canvas接口

Canvas API主要包括画布操作及对象绘制两大类:

画布操作包括Matrix矩阵变化、Blend融合模式、画布裁切等设置,通过对canvasState画布状态的操作实现绘制上下文的切换

对象绘制包括Path、Shape、Image以及Glyph等对象的绘制,结合Paint画笔实现纹理、文本、图形、蒙版等多种形式的绘制及渲染

class Canvas {
 // 画布操作
  void setMatrix(const Matrix& matrix);
  void setAlpha(float newAlpha);
  void setBlendMode(BlendMode blendMode);

  // 绘制API
  void drawRect(const Rect& rect, const Paint& paint);
  void drawPath(const Path& path, const Paint& paint);
  void drawShape(std::shared_ptr<Shape> shape, const Paint& paint);
  void drawImage(std::shared_ptr<Image> image, const Matrix& matrix, const Paint* paint = nullptr);
  void drawGlyphs(const GlyphID glyphIDs[], const Point positions[], size_t glyphCount,
                  const Font& font, const Paint& paint);
};
// CanvasState记录当前画布的状态,包括Alph、blend模式、变化矩阵等
struct CanvasState {
  float alpha = 1.0f;
  BlendMode blendMode = BlendMode::SrcOver;
  Matrix matrix = Matrix::I();
  Path clip = {};
  uint32_t clipID = kDefaultClipID;
};

// 通过save及restore实现绘制状态的切换
void Canvas::save() {
  auto canvasState = std::make_shared<CanvasState>();
  *canvasState = *state;
  savedStateList.push_back(canvasState);
}

void Canvas::restore() {
  if (savedStateList.empty()) {
    return;
  }
  state = savedStateList.back();
  savedStateList.pop_back();
}

DrawOp执行

DrawOp负责实际的绘制逻辑,比如OpenGL着色器函数的创建装配、顶点及纹理数据的创建及绑定等

TGFX抽象了FillRectOp矩形绘制Op,可以覆盖绝大多数场景的绘制需求

当然,其还支持其它类型的绘制Op,比如ClearOp清屏、TriangulatingPathOp三角图形绘制Op等

class DrawOp : public Op {
  // DrawOp通过Pipiline实现多个_colors纹理对象及_masks蒙版的绘制
  std::vector<std::unique_ptr<FragmentProcessor>> _colors;
  std::vector<std::unique_ptr<FragmentProcessor>> _masks;
};

// 矩形实际绘制执行者
class FillRectOp : public DrawOp {
  FillRectOp(std::optional<Color> color, const Rect& rect, const Matrix& viewMatrix,
             const Matrix& localMatrix);
  void onPrepare(Gpu* gpu) override;
  void onExecute(OpsRenderPass* opsRenderPass) override;
};

总结

本文结合OpenGL讲解了TGFX渲染引擎的大概框架结构,让各位有了一个初步认知

接下来将结合image纹理绘制介绍TGFX渲染引擎详细的绘制渲染流程,欢迎大家关注点赞!

深入理解redux

前沿

在使用 react 的过程中,通常我们会通过 props 将父组件的一些数据传递到子组件,兄弟组件传递数据通过一个共同的父级,子传父可以通过回调函数来进行传递,当然这都是比较理想的情况,业务中往往不可能仅仅这样简单,最常见的一点就是跨很多层级的传递,你不可能一层层的通过 props 传递,这会让你的 props 变的异常臃肿不便

当然现实中,相信大多数人都会选择 react-redux,只要是 react 的项目肯定离不开 react-redux,它已然成为较为标准的 react 的状态管理框架,在横跨多个层级之间的状态共享、响应式变化方面起着尤为重要的作用

react 官方也提供了一些多层级传递的方式,像 context,它能够允许父组件向其下面的所有子组件提供信息,不需要 props 显式传递,举个例子,比如我们要共享登陆的用户数据,先创建一个 context

const UserContext = React.createContext();

export default UserContext;

然后在顶层引入这个导出的 context,使用 provider 包裹,被 provider 包裹的所有的组件包括所有子组件都可以享受到这个 value 值

<UserContext.Provider value={user}>
  <div className="App">
    <UserProfile />
  </div>
</UserContext.Provider>

// 所有子组件都可以使用 useContext hook 进行获取数据
const user = useContext(UserContext);

这个数据从顶层保证了单一的数据源,如果需要修改,结合 react 当中的 reducer hook 进行数据的更改

那是不是这样就可以解决我们的问题了呢?表面上的问题是解决了,但是使用 context 会存在一些问题

  1. 难以追踪数据流:因为 context 中的数据是能够被任何组件访问以及修改,所以大的项目中对于数据的更改或者流动不容易预测,开发过程中想要知道数据来源进行一些调试变的异常困难
  2. 降低组件的可复用性:因为 context 会导致组件和数据耦合度较高,如果一个组件依赖了 context,那它的复用性就异常困难
  3. 性能问题:如果 context 中的数据频繁变化,就会导致你的页面从头到底频繁刷新,效率降低,你需要使用大量的 uesMemo 进行优化
  4. 一定的学习成本:需要注意的是,context 是可以嵌套的,类似 css 属性继承那样,如果上层的 context 的值被下层嵌套处理,则 context 中的值会不断叠加

一般 context 的应用场景是在主题颜色、当前登陆账户信息、路由等

既然 context 存在这样那样的问题,那有没有好一点的方式呢?那就是 redux

Flux

在讲 redux 之前,我们先了解一下 flux,为什么要先说 flux,主要原因是因为它是 redux 的鼻祖,可以说 redux 模仿的 flux 的架构思想,它们都有一个贯彻始终的思想,单向数据流

flux 单向数据流上面的图也表明了对应的流动关系,具体的过程就是,store 中保存了用到的具体的数据,store 发生变化的时候,就会导致 view 层的更新,如果 view 触发了一个 action,比如点击了某个事件,数据会发生一定的变化,view 会将这个 action 通过中央 dispatcher 传播到 store,store 本身已有对应的处理逻辑,处理对应的状态以及数据的变化,然后再触发 view 层的更新

那在这之前,传统的架构模式一般都是 MVC 架构,也就是模型、视图和控制器,模型(model)主要是负责应用程序中的数据和业务逻辑,视图(view)负责呈现数据以及用户界面,控制器(controller)则是负责协调模型和视图之间的交互,从这里其实就是可以看出,MVC 模式更加关注数据和业务逻辑的组织和管理,而 Flux 模式更加关注应用程序的数据流和状态管理,针对大型应用而言,Flux 的模式更适合处理数据,因为一切都是可控的。如果你用 MVC 的架构模式,每当添加一个新的功能,系统的复杂度就会疯狂增加

这种双向流动的数据,对于开发来说是难以接受的,很难理清其中的关系,并且当你修改其中的某一个内容的时候,影响点是无法准确评估的

既然 flux 是祖先,那为什么现在我们很少用 flux 呢?

首要的就是 flux 的学习成本较高,设计比较复杂,如果你要用 flux 的模式,你需要编写大量的代码,包括 Action、Dispatcher、Store 和 View 等组件,并且只适用于大型应用,小型应用很容易被这个复杂化的设计提升项目本身的难度,最主要的一点,业界已经有较好的方式,弥补了这些不足,比如我们要说的 redux,还有较好的 mobx,它们更简单,高效,易学习

Redux

既然 redux 比 flux 更优秀,那具体哪里优秀呢?或者它解决了 flux 上面的一些问题了么?首先单向数据流这个概念是不变的,在这个基础上,redux 还做了一些额外的能力

唯一数据源,flux 我们知道可以创建多个 store,但是这样导致的问题就是数据冗余,不同 store 之间又相互依赖增加了更多的复杂度,redux 的方式就是让整个应用使用一个 store,当然它不会阻止你创建多个

不能直接修改数据,改变只能靠纯函数,而纯函数就是 reducer

reducer(state, action) => newState

保证 reducer 是纯函数那就不应该直接改变原有的 state,而是返回一个新的 state,当传递相同的参数时,每次调用的返回结果应该是一致的,所以也要避免使用 Date.now() 或 Math.random() 这样的非纯函数,这样产生的结果是不可控的,针对不同的 action 在 reducer 函数内部处理,区分不同的 action 返回不同的 state,创建一个简单 reducer 类似下面这样,借用官网事例

function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case 'counter/incremented':
      return { value: state.value + 1 }
    case 'counter/decremented':
      return { value: state.value - 1 }
    default:
      return state
  }
}

state 的初始状态我们也给了对应的值,有了 reducer,我们需要创建一个 store,方式也很简单,通过 redux 提供的 createStore 进行创建,然后通过 subscribe 进行订阅,当 store 的数据发生变化的时候就会触发订阅的回调函数,改变内部状态的唯一方法是 dispatch 一个 action,代码如下

let store = createStore(counterReducer)
store.subscribe(() => console.log(store.getState()))
store.dispatch({ type: 'counter/incremented' })
// {value: 1}

可以看到,我们 dispatch 的 action 仅仅是通过 type 来描述我们干了什么,然后通过 reducer 返回一个新的 state,最后触发 订阅的回调函数,打印出来最新的 store 值

这个时候你会发现 redux 是可以独立使用的,也就是 react 和 redux 是两个独立的东西,你可以用 redux 而不用 react,如果两个真的要结合使用,可以用 react-redux 的库,会极大的简化代码,当然如果你了解了 redux 的原理,react-redux 也会轻松拿捏

mini-redux

功能有了,那如何实现这么一个简单的 redux 呢?其核心的内容也就是 createStore,然后暴露出来一系列的 subscribe、dispatch、getStore 等方法,用不到 20 行代码简单实现一下

function createStore(reducer) {
    var state;
    var listeners = []

    function getState() {
        return state
    }
    
    function subscribe(listener) {
        listeners.push(listener)
        return function unsubscribe() {
            var index = listeners.indexOf(listener)
            listeners.splice(index, 1)
        }
    }
    
    function dispatch(action) {
        state = reducer(state, action)
        listeners.forEach(listener => listener())
    }

    dispatch({})

    return { dispatch, subscribe, getState }
}

简单解释一下

这段代码定义了一个名为 createStore 的函数,该函数接受一个 reducer 函数作为参数,并返回一个包含 dispatch、subscribe 和 getState 方法的对象。

getState 方法用于获取当前的状态值,subscribe 方法用于注册一个监听器,dispatch 方法用于执行某个操作并更新状态,并通知所有注册的监听器。

在函数内部,定义了一个 state 变量和一个 listeners 数组,用于存储状态和监听器。在 dispatch 方法中,执行 reducer 函数来更新状态,并遍历 listeners 数组,依次调用每个监听器。最后,调用 dispatch({}) 来初始化状态,并返回包含 dispatch、subscribe和getState 方法的对象

中间件

redux 还有较为强大的一点就是中间件,如果你对服务端相关的框架有一定的了解,对中间件肯定不会陌生。举个例子,如果你在每次 dispatch 相关内容的时候需要打一个日志,如果没有中间件你会这样做,借用官网的例子

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

当然你可以进一步封装一个方法,使用这个方法就可以加上对应的日志

function dispatchAndLog(store, action) {
  console.log('dispatching', action)
  store.dispatch(action)
  console.log('next state', store.getState())
}

// 调用
dispatchAndLog(store, action)

这样虽然可以,但是你需要在任何使用 log 的地方都需要引入这个函数,并且如果有多个 middleware 使用还是不方便,能不能提供一种可以组合多个 middleware 的方式呢?中间省略了 n 个过程,对整个过程感兴趣的可以阅读官网整个流程,理解middleware

// 假设有这样一个 middleware,返回一种新的 dispatch
const logger = store => next => action => {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

// 可以组合任意 middleware
function applyMiddleware(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()
  let dispatch = store.dispatch
  middlewares.forEach(middleware => (dispatch = middleware(store)(dispatch)))
  return Object.assign({}, store, { dispatch })
}

当然,redux 给你提供了立即可用的 applyMiddleware 直接组合你的中间件

react-redux

有了 redux,要把 react 和 redux 进行较好的结合,就像刚开始提到的,如果仅仅是数据传递,使用 context 之后会导致额外的一些性能问题都需要手动处理,但是 react-redux 在内部实现了许多性能优化,以便你编写的组件仅在实际需要时重新渲染,并且使用一些 hook 的形式极大简化了我们的代码和逻辑,如果你要在 react 项目中使用 redux,那就 @reduxjs/toolkit react-redux

之前我们没说到 redux toolkit,redux 我们也看到了,在实际业务中编写 reducer 又臭又长,而 toolkit 就是在 redux 的基础上能够简化了大多数 Redux 任务,避免了常见错误,使得编写 Redux 应用程序更容易了,可以把它称为 redux 的最佳实践

总结

redux 是一个 JavaScript 状态容器,用于管理应用程序状态。redux 的三个原则:单一数据源、状态是只读的、使用纯函数来执行状态更改。文章描述了如何应用它们以及它们的好处。redux 的核心概念包括 store、action、reducer 和 middleware。redux 使用 action 来描述状态更改,reducer 根据 action 来更新状态,而 middleware 则用于处理异步操作和副作用

redux toolkit是一个官方推荐的 redux 工具集,它提供了一些简化 redux 开发的工具和 API,例如 createSlice、createAsyncThunk 和 createEntityAdapter 等。使用 redux toolkit 可以更容易地编写可维护和可扩展的 redux 代码,并减少样板代码的数量

iOS Swift开发面试题总结

Swift 优点 (相对 OC)

  • Swift 更加安全,是类型安全的语言
  • 代码少,语法简洁,可以省去大量冗余代码
  • Swift 速度更快,运算性能更高,(Apple 专门对编译器进行了优化)

Swift 中 类(class) 和 结构体(struct) 的区别,以及各自优缺点?

  • 类:
    • 引用类型
      • 在进行变量赋值时,是通过指针copy,属于浅拷贝(shallow copy)
      • 数据的存储是在堆空间
    • 可以被继承(前提是类没有被 final 关键字修饰),子类可以使用父类的属性和方法
    • (当class继承自 Object,拥有runtime机制)类型转换可以在运行时检查和解释一个实例对象
    • 用 deinit(析构函数)来释放资源 类似OC(dealloc)
    • 类的方法地址 是不确定的,只有在具体运行时,才能确定调用的具体值
  • 结构体
    • 值类型
      • 在进行变量赋值是,是深拷贝(deep copy),产生了新的副本
      • 数据的存储时在栈空间(大部分情况下,不需要考虑内存泄露问题,栈空间的特点是用完即释放)
    • 结构体调用方法,在编译完成就可以确定方法具体的地址值,以便直接调用

综上,在满足程序要求的情况下 优先使用 结构体


Swift中strong 、weak和unowned是什么意思?二者有什么不同?何时使用unowned?

Swift 的内存管理机制与 Objective-C一样为 ARC(Automatic Reference Counting)。它的基本原理是,一个对象在没有任何强引用指向它时,其占用的内存会被回收。反之,只要有任何一个强引用指向该对象,它就会一直存在于内存中。

  • strong 代表着强引用,是默认属性。当一个对象被声明为 strong 时,就表示父层级对该对象有一个强引用的指向。此时该对象的引用计数会增加1。

  • weak 代表着弱引用。当对象被声明为 weak 时,父层级对此对象没有指向,该对象的引用计数不会增加1。它在对象释放后弱引用也随即消失。继续访问该对象,程序会得到 nil,不亏崩溃

  • unowned 与弱引用本质上一样。不同的是,unowned 无主引用 实例销毁后仍然存储着实例的内存地址(类似于OC中的unsafe_unretained), 试图在实例销毁后访问无主引用,会产生运行时错误(野指针)

  • weak unowned 只能用在 类实例上面

  • weakunowned 都能解决 循环引用,unowned 要比 weak 性能 稍高

    • 在生命周期中可能会 变成 nil 的使用 weak
    • 初始化赋值以后再也不会变成 nil 使用 unowned

Swift 中什么是可选类型?

  • Swift中可选类型为了表示 一个变量 允许为 空(nil)的情况
  • 类型名称后 加 ? 定义 可选项
  • 选项的本质是 枚举类型

Swift 中什么 是 泛型?

  • 跟JS和Dart 类似,泛型 可以将类型 参数化,提高代码复用率,减少代码量
  • Swift泛型函数 并不会 在底层 生成 若干个 (匹配类型)函数 ,产生函数重载,而是: 在函数调用时,会将 参数的类型 传递给 目标函数
  • Swift泛型应用在协议上时,需要使用关联类型(associatedtype)

怎么理解 Swift中的泛型约束

泛型约束 可以 更精确的知道 参数 需要 遵循什么标准

//someT遵循的是某个class,someU遵循的是某个协议,这样在传参的时候明确参数类型
func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // 这里是泛型函数的函数体部分
}

Swift 中 static 和 class 关键字的区别

在 Swift 中 static 和 class 都是表示「类型范围作用域」的关键字。

在所有类型(class[类]、struct、enum )中使用

  • static 修饰都可以表示类方法类与属性(包括存储属性和计算属性)。
  • class 是专门用在 calss 类型中修饰类方法和类的计算属性(注意:无法使用 class 修饰存储属性)。

结构体只能用 static 修饰 类方法或属性

class类型 static class 的区别

在 class 类型中 static 和 class 都可以表示类型范围作用域,那区别是

  1. class 无法修饰存储属性,而 static 可以。
  2. 使用 static 修饰的类方法和类属性无法在子类中重载。也就是说 static 修饰的类方法和类属性包含了 final 关键字的特性。相当于 final class 。

一般在 protocol定义一个类方法或者类计算属性推荐使用 static 关键字来修饰。使用 protocol 时,在 struct 和 enum 中仍然使用 static,在 class 类型中 class 和 static 关键字都可以使用。


Swift 中的模式匹配?

模式 是用于 匹配的规则,比如 switch 的 case 、捕捉错误的 catch 、 if guard while for 语句条件等


Swift 中的访问控制?

Swift 提供了 5个 不同的访问级别,从高到低排列如下:

  • open
    • 允许在任意模块中访问、继承、重写
    • open 只能用在 类 、 类成员上
  • public
    • 允许在任意模块中访问
    • 修饰类时不允许其他模块进行继承、重写
  • internal - 默认
    • 只允许在定义实体的模块中访问,不允许在其他模块中访问
  • fileprivate
    • 只允许在定义实体的源文件中访问
  • private:
    • 只允许在定义实体的封闭声明中(作用域)访问

怎么理解 copy - on - write? 或者 理解Swift中的写时复制

值类型在复制时,复制对象 与 原对象 实际上在内存中指向同一个对象,**当且仅当 ** 修改复制的对象时,才会在内存中创建一个新的对象,

  • 为了提升性能,Struct, String、Array、Dictionary、Set采取了Copy On Write的技术
  • 比如仅当有“写”操作时,才会真正执行拷贝操作
  • 对于标准库值类型的赋值操作,Swift 能确保最佳性能,所有没必要为了保证最佳性能来避免赋值

原理

在结构体内部用一个引用类型来存储实际的数据,

  • 在不进行写入操作的普通传递过程中,都是将内部的reference的应用计数+1,
  • 当进行写入操作时,对内部的 reference 做一次 copy 操作用来存储新的数据;防止和之前的reference产生意外的数据共享。

swift中提供[isKnownUniquelyReferenced]函数,他能检查一个类的实例是不是唯一的引用,如果是,我们就不需要对结构体实例进行复制,如果不是,说明对象被不同的结构体共享,这时对它进行更改就需要进行复制。

Swift 为什么将 Array,String,Dictionary,Set,设计为值类型?

值类型 相比 引用类型的优点

  • 值类型和引用类型相比,最大优势可以高效的使用内存;
  • 值类型在栈上操作,引用类型在堆上操作;
  • 栈上操作仅仅是单个指针的移动,
  • 堆上操作牵涉到合并,位移,重链接

Swift 这样设计减少了堆上内存分配和回收次数,使用 copy-on-write将值传递与复制开销降到最低

String,Array,Dictionary设计成值类型,也是为了线程安全考虑。通过Swift的let设置,使得这些数据达到了真正意义上的“不变”,它也从根本上解决了多线程中内存访问和操作顺序的问题


什么是属性观察器?

属性观察是指在当前类型内对特性属性进行监测,并作出响应,属性观察是 swift 中的特性,具有2种, willset和 didset

  • willSet 传递新值 newValue

  • didSet 传递旧值 oldValue

  • 在初始化器中对属性初始化时,不会触发观察器

  • 属性观察器 只能用在 存储属性 ,不可用在 计算属性

  • 可以 为  lazy (即延迟存储属性)的 var 存储属性 设置 属性观察器

  • willSet 会传递新值,默认叫 newValue
  • didSet 会传递旧值,默认叫 oldValue

注意:

  • 在初始化器中设置属性值不会触发 属性观察器
    • 属性定义时设置初始值也不会出发 属性观察,原因是
      • 属性定义时设置初始值,本质跟在 初始化器中设置值是一样的
  • 属性观察器 只能用在 存储属性 ,不可用在 计算属性
struct Cicle {
    /// 存储属性
    var radius :Double {
        willSet {
            print("willSet -- ",newValue,"radius == ",radius)
        }
        didSet {
            print("didSet ++ ",oldValue,"radius == ",radius)
        }
    }
    /*
     上述代码 跟下面等价,不推荐
     var radius :Double {
         willSet(jk_newValue) {
             print("willSet -- ",jk_newValue,"radius == ",radius)
         }
         didSet(jk_oldValue) {
             print("didSet ++ ",jk_oldValue,"radius == ",radius)
         }
     }
     */
}

var circle = Cicle.init(radius: 10.0)

circle.radius = 20.0
/*
 willSet --  20.0 radius ==  10.0
 didSet ++  10.0 radius ==  20.0
 */

print("result == ",circle.radius)
//result ==  20.0

拓展:
●属性观察器,计算属性这两个功能,同样可以应用在全局变量/局部变量
●属性观察器,计算属性 不可以同时 应用在同一个类(不包括继承)的属性中

Swift 异常捕获

do - try - catch 机制

  • Swift中可以通过 Error 协议自定义运行时的错误信息
  • 函数内部通过throw 抛出 自定义  Error , 抛出 Error 的函数必须加上 throws 声明(逻辑通过不会抛出,反之可能抛出)
  • 需要使用 try 调用 可能会 抛出 的Error 函数
  • 通过 try 尝试调用 函数 抛出的异常 必须要 处理异常;否在会编译报错; 反之 运行时 在 top level (main) 报错,闪退

defer 的用法

  • 使用defer代码块来表示在函数返回前,函数中最后执行的代码。无论函数是否会抛出错误,这段代码都将执行。

如何将Swift中协议 部分方法 设计成可选?

  • 方案一(不推荐,除非需要暴露给OC用)
    • 在协议和方法前面添加 @objc,然后在方法前面添加 optional关键字,改方式实际上是将协议转为了OC的方式
@objc protocol someProtocol {
  @objc  optional func test()
}

协议 可以 用来定义 属性 方法 下标 的 声明,协议 可以被  类 结构体 枚举 遵守(多个协议之间用, 隔开)

protocol Drawable {
    func draw()
    var x:Int { get set }
    var y:Int { get }
    subscript(index:Int) -> Int {get set}
}

protocol Test1 {}
protocol Test2 {}
protocol Test3 {}

class TestClass: Test1,Test2,Test3 {
    
}

注意:

  • 协议中定义的方法,不能有默认参数
  • 默认情况下,协议中定义的内容需要全部实现

Swift和OC中的 protocol 有什么不同?

  • 相同点,两者都是用作代理
  • 不同点
    • Swift
      • Swift中的 protocol 还可以对接口进行抽象,可以实现面向协议,从而大大提高编程效率
      • Swift中的 protocol 可以用于值类型,结构体,枚举;

比较Swift 和OC中的初始化方法 (init) 有什么不同?

swift 的初始化方法,因为引入了两段式初始化和安全检查因此更加严格和准确,

swift初始化方法需要保证所有的非optional的成员变量都完成初始化,

同时 swfit 新增了convenience和 required两个修饰初始化器的关键字

  • convenience只提供一种方便的初始化器,必须通过一个指定初始化器来完成初始化
  • required是强制子类重写父类中所修饰的初始化方法

Swift 和OC 中的自省 有什么区别?

  • OC
    • 自省在OC中就是判断某一对象是否属于某一个类的操作,有以下2中方式
      • [obj iskinOfClass:[SomeClass class]] : obj 必须是 SomeClass 的 对象或 其子类对象;return YES;
      • [obj isMemberOfClass:[SomeClass class]] : obj 必须是 SomeClass 的 对象;return YES;
  • Swift
    • Swift 中由于很多 class 并非继承自 NSObject, 故而 Swift 使用 is 来判断是否属于某一类型, is 不仅可以作用于class, 还可以作用于enum和struct

Swift 与 OC 如何相互调用

  • Swift -> OC
    • 需要创建一个 Target-BriBridging-Header.h (默认在OC项目中,会提示自动创建)的桥文件,在该文件中,导入需要调用的OC代码的头文件即可
  • OC -> Swift
    • 直接导入Target-Swift.h(该文件是Xcode自动创建) Swift如何需要被OC调用,需要使用 @objc 对方法或属性进行修饰

Swift 中特殊的标记

imagepng

Swift调用OC
●新建一个桥接文件,文件格式默认为:{targetName}-Bridging-Header.h;(一般在OC项目中,创建Swift文件,Xcode会自动提示生成该文件,仅需点击确认即可)

imagepng

●在{targetName}-Bridging-Header.h 文件中 #import OC 需要 暴露 给 Swift的内容

OC 调用 Swift
●Xcode 已经默认 生成 一个 用于 OC 调用 Swift的头文件,文件名格式是: {targetName}-Swift.h

imagepng

●Swift 暴露给 OC的 类 一定要继承 NSObject

●使用 @objc 修饰 需要暴露 给 OC的成员

●使用@objcMembers 修饰类
○代表 默认所有的 成员 都会 暴露给 OC(包括扩展中定义的成员)
○最终 是否成功 暴露,还需要考虑 成员自身的 访问级别

拓展
●为什么Swift 暴露给 OC 的类 要最终 继承 NSObject?
因为 OC 中的方法调用 是通过 Runtime 机制,需要通过 isa 指针 去完成 一些列消息的发送等, 而 只有继承自 NSObject 的类 才具有 isa 指针,才具备 Runtime 消息 发送的能力
●p.run() 底层是如何调用的? 反过来,OC调用Swift 又是如何调用?
○JKPerson 是 OC 的类,以及OC 中定义的初始化 和 run 方法
○在Swift中 调用 JKPerson 对象的 run 方法 ,底层是如何调用的?

Swift复制代码

var p = JKPerson(age: 10,name:"Jack")
p.run()

答:走 Runtime 运行时机制, 反过来 OC 调用 Swift中的类 跟 问题一 一样,也是通过 Runtime 机制
●car.run() 底层是如何调用的?

swift

答 : (虽然 Car 类 被暴露给 OC使用)在Swift中 car.run(),底层依然是 通过 类似 C++ 的虚表 机制 来调用的;

拓展:
如果想要 Swift中的方法 调用 也使用 Runtime 机制,需要在方法名称前面 加上 dynamic关键字


Swift定义常量 和 OC定义常量的区别?

//OC:
const int price = 0;
//Swift:
let price = 0
  • OC中 const 常量类型和数值是在编译时确定的
  • Swift 中 let 常量(只能赋值一次),其类型和值既可以是静态的,也可以是一个动态的计算方法,它们在运行时确定的。

Swift 中的函数重载

构成函数重载的规则

  • 函数名相同
  • 参数个数不同 || 参数类型不同 || 参数标签不同

注意: 返回值类型 与函数重载无关;返回值类型不同时,函数重载会报错:

func overloadsum(v1 : Int,v2:Int) -> Int {
    v2 + v1
}

// 参数个数不同
func overloadsum(v1 : Int,v2:Int,v3:Int) -> Int {
    v2 + v1 + v3
}

// 参数类型不同
func overloadsum(v1 : Int,v2:Double) -> Double {
    v2 + Double(v1)
}

// 参数标签不同
func overloadsum(_ v1 : Int,_ v2:Int) -> Int {
    v2 + v1
}

func overloadsum(a : Int,_ b:Int) -> Int {
    a + b
}

/**
 返回值类型不同时,在函数重载时,会报错:
 Ambiguous use of 'overloadsum(v1:v2:)'
 
 func overloadsum(v1 : Int,v2:Int) {
 }
 */

public func overloadtest() {
    let result1 = overloadsum(v1: 10, v2: 20)
    let result2 = overloadsum(v1: 10, v2: 20, v3: 30)
    let result3 = overloadsum(v1: 10, v2: 20)
    let result4 = overloadsum(10, 20)
    let resutt4_1 = overloadsum(a: 10, 20)
    
    print(result1,result2,result3,result4,resutt4_1)
    //30 60 30 30 30
}


Swift 中的枚举,关联值 和 原始值的区分?

    • 将 枚举的成员值 跟 其他类型的值 关联 存储在一起
    • 存储在枚举变量中,占用枚举变量内存
enum Score {
    case points(Int)
    case grade(Character)
}
    • 枚举成员可以使用相同类型的默认值预先关联,这个默认值叫做:原始值
    • 不会存储在 枚举变量中,不占用枚举变量内存
enum PokerSuit : Character {
    case spade = "♠"
    case heart = "♥"
    case diamond = "♦"
    case club = "♣"
}

闭包是引用类型吗?

闭包和函数都是是引用类型。如果一个闭包被分配给一个变量,这个变量复制给另一个变量,那么他们引用的是同一个闭包,他们的捕捉列表也会被复制。

swift 中的闭包结构是什么样子的?

{
    (参数列表) -> 返回值类型 in 函数体代码
}

什么是尾随闭包

  • 尾随闭包 是一个被 书写在 函数调用 括号 后面的 闭包表达式

基本定义
●Swift中可通过 func 定义一个函数,也可以通过 闭包表达式 定义一个函数

闭包表达式的格式:

{
    (参数列表) -> 返回值类型  in
    函数体代码
}

闭包表达式与定义函数的语法相对比,有区别如下:
1没有func
2没有函数名
3返回值类型添加了关键字in

let fn1 = {
    (v1 : Int,v2 : Int) -> Int in
    return v1 + v2
}

let result1 = fn1(10,5)

let result2 = {
    (v1:Int,v2:Int) -> Int in
    return v2 + v1
}(10,6)

print(result1,result2) // 15 16

闭包表达式的简写

func exec(v1:Int,v2:Int,fn:(Int,Int)->Int) {
    print(fn(v1,v2))
}

闭包表达式的简写
private func test2() {
    // 1: 没有简写
    exec(v1:10, v2:20, fn: {
        (v1:Int,v2:Int) -> Int in
        return v1 + v2
    })
    
    // 2: 简写1
    exec(v1: 2, v2: 3, fn: {
        v1,v2 in return v1 + v2
    })
    
    // 3:简写 2
    exec(v1: 3, v2: 4, fn: {
        v1,v2 in v1 + v2
    })
    
    // 4: 简写3
    exec(v1: 5, v2: 6, fn: {$0 + $1})
    
    // 5: 简写4
    exec(v1: 7, v2: 8, fn: +)
}

尾随闭包

  • 将一个很长的闭包表达式作为函数的最后一个实参,使用尾随闭包可以增强代码的可读性
    • 尾随闭包 是一个被 书写在 函数调用 括号 后面的 闭包表达式
func test3() {
    
    exec(v1: 8, v2: 7) { a, b in
        a + b
    }
    
    // or     { 书写在 函数调用 括号 后面的 闭包表达式}
    exec(v1: 9, v2: 10) {
        $0 + $1
    }
}
  • 如果 闭包表达式 是函数的唯一 实参,且使用了尾随闭包的 语法,则在函数名后面的 () 可省略
// fn:就是尾随闭包
func exec1(fn:(Int,Int)->Int) {
    print(fn(1,2))
}

exec1(fn: {$0 + $1})
exec1() {$0 + $1}
exec1{$0 + $1}

什么是逃逸闭包

  • 闭包有可能在函数结束后调用,闭包调用 逃离了函数的作用域,需要通过@escaping 声明

注意:逃逸闭包 不可以 捕获 inout 参数

原因是: 逃逸闭包不确定 何时开始执行,有可能 在执行逃逸闭包时,可变参数已经被程序回收,造成野指针访问

什么是自动闭包

自动闭包是一种自动创建的用来把作为实际参数传递给函数的表达式打包的闭包。

它不接受任何实际参数,并且当它被调用时,它会返回内部打包的表达式的值。

这个语法的好处在于通过写普通表达式代替显式闭包而使你省略包围函数形式参数的括号。

func getFirstPositive(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int? {
    return v1 > 0 ? v1 : v2()
}
getFirstPositive(10, 20)
  • 为了避免与期望冲突,使用了@autoclosure的地方最好明确注释清楚:这个值会被推迟执行
  • @autoclosure 会自动将 20 封装成闭包 { 20 }
  • @autoclosure 只支持 () -> T 格式的参数
  • @autoclosure 并非只支持最后1个参数
  • 有@autoclosure、无@autoclosure,构成了函数重载

如果你想要自动闭包允许逃逸,就同时使用 @autoclosure 和 @escaping 标志。


Swift 中的存储属性与计算属性

存储属性(Stored Property)

  • 类似于成员变量这个概念
  • 存储在实例对象的内存中
  • 结构体、类可以定义存储属性
  • 枚举不可以定义存储属性

关于 存储属性, Swift 中有个明确的规定

  • 在创建 类 或者 结构体 的实例时,必须为所有的存储属性设置一个合适的初始值
    • 可以在初始化器里为存储属性设置一个初始值
    • 可以分配一个默认的属性值作为属性定义的一部分

计算属性(Computed Property)

  • 本质就是方法(函数)
  • 不占用实例对象的内存
  • 枚举、结构体、类都可以定义计算属性

计算属性(Computed Property)
○本质就是方法(函数)
○不占用实例的内存
○枚举、结构体、类 都可以定义计算属性

理解计算属性与存储属性:

如果两个属性之间存在一定的逻辑关系,使用计算属性,原因如下:
●如果都用存储属性的话,其逻辑对应关系可能有误
●而使用计算属性,则可以准确的描述 这种逻辑关系
具体案例参考 下面的 Cicle 中的 radius(半径) 和 diameter(直径) 的逻辑关系

同时因为计算属性不占用实例的内存,可以有效的节省实例的内存空间

●set 传入的新值 默认叫做 newValue,也可以自定义

struct Cicle {
    /// 存储属性
    var radius :Double
    /// 计算属性
    var diameter: Double {
        get {
            radius * 2
        }
        set (jkNewValue){
            radius = jkNewValue / 2.0
        }
    }
}
  • 只读计算属性:只有 get , 没有 set
struct Cicle {
    /// 存储属性
    var radius :Double
    /// 计算属性
    /*
     var diameter: Double {
         get {
             radius * 2
         }
     }
     */

    // 上述代码与下面的代码等价
    var diameter: Double {radius * 2}
}

var cicle = Cicle.init(radius: 12)

print(cicle.radius)//12.0
print(cicle.diameter)//24.0

// cicle.diameter = 10.0 //Cannot assign to property: 'diameter' is a get-only property
  • 定义计算属性只能用 var,不可以是 let
    • let 表示常量
    • 计算属性的值是可能会发生变化的(包括只读计算属性)

什么是[延迟存储属性](Lazy Stored Property)

  • 使用 lazy 可以定义一个 延迟存储属性,在第一次用到属性的时候才会进行初始化(类似 OC 中的懒加载)

注意点:

  • lazy 属性 必须是 var
    • 因为,let 属性 必须在实例的初始化方法 完成之前 就拥有值
  • 如果多条线程同时第一次访问 lazy 属性,无法保证 属性 只被 初始化 一次 (即线程不是安全的)

  • 使用 lazy 可以定义一个 延迟存储属性,在第一次用到属性的时候才会进行初始化(类似 OC 中的懒加载)

注意点:

  • lazy 属性 必须是 var
    • 因为Swift 规定:let 属性 必须在实例的初始化方法 完成之前 就拥有值
  • 如果多条线程同时第一次访问 lazy 属性,无法保证 属性 只被 初始化 一次 (即线程不是安全的)
class Car {
    init() {
        print("car has init")
    }

    func run() {
        print("car running")
    }
}

class Person {
    lazy var car = Car.init()

    init() {
        print("person has init")
    }

    func go_out() {
        car.run()
    }
}

let p = Person.init() //person has init
print("*******")
p.go_out()//  car has init ---->   car running
  • 当结构体 包含 一个延迟存储属性时,只有 var 才能访问延迟 存储属性
    • 因为延迟属性 初始化时 要改变 结构体的内存
struct Point {
    var x = 0
    var y = 0
    lazy var z = 0
}

let p = Point.init()
print(p.z)//Cannot use mutating getter on immutable value: 'p' is a 'let' constant

什么是 类型 属性?

  • 类型属性(Type Property) :通过类型去访问
    • 存储类型属性(Stored Type Property):整个程序运行过程中,就只有1份内存(类似于全局变量,底层采用了 gcd_once 操作)
    • 计算类型数据(Computed Type Property):

属性可分为
●实例属性(Instance Property):通过实例去访问
○存储实例属性(Stored Instance Property):存储在实例的内存中,每个实例都有1分
○计算实例属性(Computed Instance Property):不占用实例的内存,本质是方法

●类型属性(Type Property) :通过类型去访问
○存储类型属性(Stored Type Property):整个程序运行过程中,就只有1份内存(类似于全局变量,底层采用了 gcd_once 操作,保证只初始化一次)
○计算类型数据(Computed Type Property):

注意:
存储类型属性不会 占用 实例对象 的内存,整个程序运行过程中,就只有1份内存

存储类型属性 本质就是全局变量(该全局变量加了一些类型控制,只能通过类型去访问),

可以通过 static 定义类型属性
如果 是类, 也可以使用 关键字 class
结构体 就只能使用 关键字 static

struct Car {
    static var count:Int = 0
    init(){
        Car.count += 1
    }
}

let c1 = Car.init()
let c2 = Car.init()
let c3 = Car.init()
print(Car.count)//3

类型属性的细节:
●不同于 存储实例属性 ,必须给 存储类型属性 设定初始值
○因为类型没有想实例那样的 init 初始化器来初始化 存储属性
●存储类型属性 默认就是 lazy。会在第一次使用的时候 初始化
○就算 被 多个线程 同时访问,保证只会被 初始化 一次,线程是安全的
○存储类型 属性 也可以是 let
●枚举类型 也可以 定义 类型属性(存储类型属性,计算类型属性)


Swift 中如何定义单例模式

可以通过类型属性+let+private 来写单例; 代码如下如下:

// 方式一
public class SingleManager{
    public static let shared = SingleManager()
    private init(){}
}

// 方式二
public class SingleManager{
    public static let shared = {
        //...
        //...
        return SingleManager()
    }()
    private init(){}
}

// 上述两个方法等价,一般推荐 方式二

Swift 中 下标是什么?

  • 使用subscript 可以给任意类型(枚举,结构体,类) 增加下标的功能

subscript 的语法 类似于 实例方法、计算属性,本质就是方法(函数)

func xiabiaoTest() {
    class Point {
        var x = 0.0
        var y = 0.0
        
        subscript (index:Int) -> Double {
            set{
                if index == 0 {
                    x = newValue
                } else if index == 1 {
                    y = newValue
                }
            }
            get{
                if index == 0 {
                    return x
                } else if index == 1 {
                    return y
                }
                return 0
            }
        }
    }
    
    let p = Point()
    p[0] = 11.1 // 调用subscript
    p[1] = 22.2 // 调用subscript
    print(p.x)//11.1 不会调用 subscript
    print(p.y)//22/2 不会调用 subscript
    print(p[0])//11.1 // 调用subscript
    print(p[1])//22.2 // 调用subscript
}

简要说明Swift中的初始化器?

一图胜千言 针对类

  • 结构体会默认生成 含有参数的初始化器,一旦自定义初始化器,默认的初始化器则不可用
  • 类默认只会生成无参的指定初始化器

11

  • 类、结构体、枚举都可以定义初始化器
  • 类有2种初始化器: 指定初始化器(designated initializer)、便捷初始化器(convenience initializer)

什么是可选链?

可选链是一个调用和查询可选属性、方法和下标的过程,它可能为 nil 。

  • 如果可选项包含值,属性、方法或者下标的调用成功;
  • 如果可选项是 nil ,属性、方法或者下标的调用会返回 nil 。
  • 多个查询可以链接在一起,如果链中任何一个节点是 nil ,那么整个链就会得体地失败。

可选链(Optional Chaining)

如果 可选项 不会nil ,调用 方法 ,下标,属性成功,结果会被包装成 可选项,反之调用失败,返回nil

class Car { var price = 0}
class Dog { var weight = 0}
class Person {
    var name:String = ""
    var dog :Dog = Dog()
    var car :Car? = Car()
    func age() -> Int { 18 }
    func eat() {print("Person Eat")}
    subscript (index:Int) ->Int {index}
}


var person :Person? = Person()
var age1 = person!.age() //Int
var age2 = person?.age() // Int?
var name = person?.name // String?
var index = person?[6] // Int?


func getName() -> String {"jackie"}

/*
 如果 person 对象 是 nil  ,将不会调用 getName() 方法
 */
person?.name = getName()

  • 如果结果本来就是可选项,不会进行再次包装
if let _ = person?.eat() {
    /*
    Person Eat
eat success
    */
    print("eat success")
} else {
    print("eat failure")
}
  • 多个  可以连接在一起,其中任何一个节点 如果为 nil,那么整条链就会 调用失败
var dog = person?.dog // Dog?
var weight = person?.dog.weight // Int?
var price = person?.car?.price // Int?

什么是运算符重载?

类、结构体、枚举可以为现有的运算符提供自定义的实现,这个操作叫做:运算符重载

struct Point {
    var x: Int,y: Int
    static func + (p1: Point,p2: Point) -> Point {
        Point(x: p1.x + p2.x ,y: p1.y + p2.y)
    }
}
let p = Point(x:10,y: 20) + Point(x: 30,y: 40)
print(p) //Point(x: 40, y: 60)

Swift中函数的柯里化

将一个 接受 多个参数的 函数,变成 只接受 一个参数的一系列 操作

示例

func add(_ v1: Int,_ v2: Int) -> Int {
    v1 + v2
}

func difference(_ v1: Int,_ v2: Int) -> Int {
    v1 - v2
}

func add2(_ v1: Int,_ v2: Int,_ v3 :Int ,_ v4 :Int) -> Int {
    v1 + v2 - v3 + v4
}
  • 伪柯里化
func currying_add(_ v1:Int) -> (Int) -> Int {
    return {$0 + v1}
}

/*
 将任意一个 接受两个 参数的函数 柯里化
 */
func curring_fun_tow_params1(_ f1 :@escaping (Int,Int) -> Int, _ v1 :Int) -> (Int) -> Int {
//    return {
//        f1($0,v1)
//    }
    return { (v2) in
        return f1(v1 , v2)
    }
}

print(add(10, 20)) // 30
print(currying_add(10)(20)) // 30 // 30
print(curring_fun_tow_params1(add, 10)(20))
  • 正宗柯里化
func curring_fun_two_params2<A,B,C>(_ f1: @escaping (A,B) -> C) -> (A) -> ((B) -> C) {
    /*
     return {
         (a) in  // a = 3
         return {
             (b) in  // b = 8
             return  f1(a,b)
         }
     }
     */
    
    { a in { b in f1(a, b)} }
    
}

let result = curring_fun_two_params2(add)(3)(5)
print("result == ",result) //8
  • 柯里化拓展

/*
 -> (A) -> (B) -> (C) -> (D) -> E
 
 实际是 一连串 闭包的 组合  如下所示:
 
 -> (A) -> (  (B) ->    ((C)  ->   ((D) -> E))  )
 
 
 传入 A   >  一个 闭包    (B)   ->   (  (C) -> ((D) -> E)  )

 
 传入 B   >>  一个 闭包   (C)   ->   (  (D) -> E  )
 
 
 传入 C   >>  一个闭包    (D) -> E
 
 
 */


//func curring_fun_more_params<A,B,C,D,E>(_ fn: @escaping (A,B,C,D) -> (E)) -> (A) -> ((B) -> ((C) -> ((D) -> E))) {
  func curring_fun_more_params<A,B,C,D,E>(_ fn: @escaping (A,B,C,D) -> (E)) -> (A) -> (B) -> (C) -> (D) -> E {
   /*
    return {
        (a) in
        return {
            (b) in
            return {
                (c) in
                return {
                    d in
                    return fn(a,b,c,d)
                }
            }
        }
    }
    */
    
    {a in { b in { c in { d in fn(a,b,c,d)}}}}
}

let resutl2 = curring_fun_more_params(add2)(10)(20)(30)(40)
print(resutl2) // 40

let resutl2_func = curring_fun_more_params(add2)
let resutl2_func_value = resutl2_func(10)(20)(30)(40)
print(resutl2_func_value) // 40
❌