[iOS研习记]——谈谈静态库与动态库

原创
2021/11/24 17:35
阅读数 9.6K

[iOS研习记]——谈谈静态库与动态库

在iOS项目开发中,静态库和动态库我们时刻都在使用,离开了库的支持,我们将会举步维艰。比如,你要画界面,总离不开UIKit这个库吧,你要使用的各种基础数据结构,如NSString,NSArray等,也离不开Foundation这个基础库。除了官方的库外,开发中我们也会从Github等开源社区下载第三方的开源库进行使用。一般我们使用的第三方库或自己开发的库都采用静态库的方式使用,而系统提供的库大多是动态库,方便多进程共享。虽然我们天天在用库,但你对静态库和动态库真的了解么?静态库和动态库的结构是怎样的?静态库和动态库有什么区别?它们又是怎么应用的?本节博客,我们就来聊一聊这些问题。

1. 引言

静态库与动态库有很多相似之处,当然也有很多差异。

从后缀名来说,.a为后缀名的库文件是静态库,.dylib为后缀名的库文件是动态库。在iOS开发中,更多时候我们使用的库是以.framework为后缀的。framework可以是静态库,也可以是动态库,framework本身是一种打包方式。我们知道,我们在编写代码时,编写的都是“源码”,而要让计算机理解这些源码,就需要编译器对源码进行编译,将其编译成计算机可理解的“机器码”,我们每编写的一个源码文件都会被编译成一个二进制的.o文件,无论静态库还是动态库,都是.o文件的合集。仅仅只有.o文件集合而成的库文件,对于开发者来说是不够的,在开发时我们不可能在没有头文件的情况下方便的调用库中的方法,因此还需要有头文件将库中提供的接口暴露出来,还有时候,可能还需要一些其他资源,比如和页面相关的库会有内置一些图片资源等,framework的功能就是将库文件,头文件,资源文件打包在一起,方便我们进行使用。下图描述了framework文件与库文件的关系:

2. 创建一个静态库

更深入的了解静态库之前,我们可以先创建一个静态库体验下,首先使用Xcode创建一个新的工程,选择Framework,如下图所示:

创建好的framework工程模板,会生成一个和工程名相同的头文件,以及一个Resources资源文件夹,我们可以创建新的功能类文件,例如可以新建一个命名为MyLog的类和一个MyTool的类,代码如下:

MyLog.h

// MyLog.h
#import <Foundation/Foundation.h>

@interface MyLog : NSObject

+ (void)log:(NSString *)str;

@end

MyLog.m

#import "MyLog.h"

@implementation MyLog

+ (void)log:(NSString *)str {
    NSLog(@"MyLog:%@",str);
}

@end

MyTool.h

#import <Foundation/Foundation.h>

@interface MyTool : NSObject

+ (NSInteger)add:(NSInteger)a another:(NSInteger)b;

@end

MyTool.m

#import "MyTool.h"

@implementation MyTool

+ (NSInteger)add:(NSInteger)a another:(NSInteger)b {
    return a + b;
}

@end

在默认生成的库头文件中,引入这两个功能头文件,如下:

#import <Foundation/Foundation.h>

//! Project version number for MyStatic.
FOUNDATION_EXPORT double MyStaticVersionNumber;

//! Project version string for MyStatic.
FOUNDATION_EXPORT const unsigned char MyStaticVersionString[];

#import "MyLog.h"
#import "MyTool.h"

在构建framewrok前,我们可以设置此framework构建成动动态库还是静态库,我们先将其构建成静态库,设置编译选项的Mach-o Type为Static Library,如下:

之后,可以让Xcode进行Build,之后在对应的Products文件夹中可以找到生成的framework文件,如下图所示:

如果你查看此framework文件的包内容,会发现其中有5类文件,如下:

其中,_CodeSignature中存放的是framework的签名文件。

Headers中存放的是头文件,需要注意,在编译framework工程时,要将需要暴露的头文件设置为public。

Info.plist文件是当前framework的配置文件。

Modules中的modulemap文件用来管理LLVM的module map,定义组件结构。

下面,我们可以尝试使用下此静态库,使用Xcode新建一个名为LibDemo的iOS工程,将前面构建的MyStatic.framework文件直接拖入此工程中,在工程的编译选项中,找到Framework Search Paths和Header Search Paths中分别将此framework的路径与头文件的路径进行配置,如下图所示:

修改测试项目的ViewController.m文件如下:

#import "ViewController.h"
#import "MyStatic.framework/Headers/MyStatic.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSInteger a = 100;
    NSInteger b = 200;
    NSInteger c = [MyTool add:a another:b];
    [MyLog log:[NSString stringWithFormat:@"%ld", c]];
}


@end

运行代码,从控制台可以看到,我们的静态库已经可以正常工作了。你可能会觉得上面的头文件引入方式非常的丑陋,你完全可以在工程中新建一个文件夹,将framework包内的头文件拷贝过来,如下图:

这样你就可以像引用工程内的头文件一样的使用framework中的功能了:

#import "ViewController.h"
#import "MyStatic.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSInteger a = 100;
    NSInteger b = 200;
    NSInteger c = [MyTool add:a another:b];
    [MyLog log:[NSString stringWithFormat:@"%ld", c]];
}

@end

3.试试动态库吧

静态库的构建和使用过程看上去非常容易,动态库应该也与其类似。我们现在就来试试吧,使用Xcode新建一个命名为MyDylib的framework工程,将编译选项中的Mach-O Type 改为Dynamic Library,创建一些简单的测试类如下:

MyObjectOne.h

#import <Foundation/Foundation.h>

@interface MyObjectOne : NSObject

@property(copy) NSString *name;

@end

MyObjectOne.m

#import "MyObjectOne.h"

@implementation MyObjectOne

@end

MyObjectTwo.h

#import <Foundation/Foundation.h>

@interface MyObjectTwo : NSObject

@property(copy) NSString *title;

@end

MyObjectTwo.m

#import "MyObjectTwo.h"

@implementation MyObjectTwo

@end

按照同样的方式,将构建好的framework文件拖入到测试工程中,配置头文件路径,添加测试代码如下:

#import "ViewController.h"
#import "MyStatic.h"
#import "MyDylib.framework/Headers/MyDylib.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSInteger a = 100;
    NSInteger b = 200;
    NSInteger c = [MyTool add:a another:b];
    [MyLog log:[NSString stringWithFormat:@"%ld", c]];
    
    MyObjectOne *one = [[MyObjectOne alloc] init];
    one.name = @"Hello";
    [MyLog log:one.name];
}

试下编译运行,目前为止,看上去一切正常,但是当程序运行起来后会崩溃,控制台会输出如下信息:

dyld[72035]: Library not loaded: @rpath/MyDylib.framework/MyDylib

产生这个异常的原因是没有找到动态库文件,静态库的动态库的区别出现了,怎么解决这个问题呢,其实很简单,我们找到当前测试工程编译的产出可执行文件,点击显示包内容,在其中新建一个Frameworks的文件夹,将MyDylib.framework文件拷贝进入,如下图所示:

现在再运行工程,你会发现程序已经可以正常执行了。但是手动拷贝动态库到可执行文件的操作非常不优雅,如果真的要在项目中使用动态库,我们更多时候会通过自动化的脚本来实现复制库文件这一步操作。

通过这些实践,我们好像能感觉到静态库和动态库之间有些什么不同,但究竟哪里不同呢?我们带着疑问继续探索。

4.静态库和动态库的不同之处

Ⅰ. 载入的方式

出现前面动态库无法找到的原因其实是动态库与静态库的载入方式不同。

静态库:静态库在链接时,会被完整的复制到可执行文件中,如果有多个应用使用了相同的静态库,每个应用的二进制文件中都会有一份完整的静态库代码。

动态库:程序在链接时,动态库并不会被复制进二进制文件,而是在程序运行时由系统动态加载到内存供程序进行调用。由于这种特性,动态库系统可以只加载一次,应用程序共享。

对于静态库动态库载入方式来说,我们可以再深一步。首先,静态库会被完整复制进可执行文件中,这里的完整其实是不精准的,我们在引入第三方库时,往往需要在工程的Other Linker Flags中配置-Objc选项,这一项的作用是对链接优化做设置。

默认情况下,静态库在链接的时候,并不会把所有代码都复制到可执行文件,其只会复制使用到的代码,这样可以减少最终应用包的体积,但是OC语言的动态性决定了并非代码直接引用才算使用,这种连接方式经常会产生运行时的问题。

设置-Objc选项后,链接器不管代码中有没有使用,都会将OC类和其对应的Category全部加载进来。

设置-all_load选项后,链接器会把所有目标文件都加载进来,不止局限与OC文件。

设置-force_load参数可以指定强制加载某个静态库的所有目标文件,对这个静态库来说,作用与-all_load一样。

对于动态库来说,链接器就没有办法做这样的优化动作了,因为动态库是运行时加载的,链接器不知道哪些代码会被用到,因此从这一个方面来说,静态库对包大小的优化貌似会比动态库更加优异,但是真的是这样么?我们先留下个伏笔,后面再分析。

Ⅱ.文件结构不同

静态库和动态库的本质区别还是在于构建出的文件结构完全不同。可以使用MachOView工具来查看库文件。

我们先说静态库,MachOView打开的静态库结构如下:

可以看到,静态库的结构其实是比较简单的,除了库本身的一些描述文件,符号表外,基本就是其他可执行文件的集合了,在图中可以看到,每个可执行文件都会有一些头数据,这些头数据记录了可执行未见的名字,大小等信息。可以点开任意一个可执行文件,其中就是我们熟悉的各种代码段,数据段等数据了:

我们再来看动态库,其结构如下:

可以看到动态库本身就是一个可执行文件,其并不是将内部的所有.o文件做简单的集合,而是一个最终链接完成的镜像文件。由于动态库是运行时进行链接的,其无法做编译时的优化,看上去可能会增加应用包的大小,但是实际应用中,我们大多会采用-Objc参数来强制静态库链接所有OC文件,并且静态库中每一个.o文件都会有一个头信息,而动态库则省略了这部分信息,因此最终对影响应用包大小这一方面来说,并不一定静态库更优。但是有一点是确定了,静态库是编译时链接,会节省应用启动时间。往往在做优化类的项目时,没有固定的方案,我们要根据实际情况,选择最合适自己的方案。

5.动态库与运行时

Ⅰ. 动态库的加载

只要说到运行时,对开发者来说就大有可为之处。首先,我们先思考下,前面的测试工程,如果我们不拷贝动态库文件到IPA包内的时候,为什么程序运行会找不到这个库文件?又为什么我们需要将动态库拷贝进IPA包的Frameworks文件夹才行?别的文件夹不行么?

要解释上面的问题,我们还是要从动态库的加载原理上来看,可以用MachOView打开测试应用包的可执行文件,找到其中的Load Commands段,如下图所示:

可以看到,其中有一些动态库的加载指令,Foundation,UIKit等都是系统的动态库,我们可以在其详情中看到详细的加载路径,如下:

对于我们自己的MyDylib库,其加载路径如下:

可以看到,这个动态库是从@rpath/MyDylib.framework/MyDylib这个路径来加载的,这个加载路径的设置在动态库编译时就已经确定,我们可以看下MyDylib这个工程,在Xcode的编译配置选项中,找到Dynamic Library Install Name选项,如下所示:

这里的@rpath实际上是一个环境变量,在应用工程中可以配置@rpath的值,在LibDemo工程的编译选项中搜rpath,可以看到这个环境变量的配置:

现在我们清楚了,其实动态库文件不一定要放入Frameworks文件夹下,修改@rpath变量的路径即可修改动态库的加载路径。

对于动态库的这种加载方式,原则上,我们可以修改此二进制文件的加载路径,也可以直接替换包内的动态库文件,实现一些逆向注入的功能,非常酷。

Ⅱ. 代码载入动态库

动态库是在运行时被加载的,我们也可以在运行时使用代码动态的控制动态库的载入。可以将测试工程中引用MyDylib的地方全部删掉,将配置的头文件路径也去掉,我们将这个动态库拷贝进工程的Bundle中,如下:

修改ViewController类的代码如下:

#import "ViewController.h"
#import "MyStatic.h"
#import <dlfcn.h>

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    NSInteger a = 100;
    NSInteger b = 200;
    NSInteger c = [MyTool add:a another:b];
    [MyLog log:[NSString stringWithFormat:@"%ld", c]];
    
    NSString *path = [[[NSBundle mainBundle] pathForResource:@"MyDylib" ofType:@"framework"] stringByAppendingString:@"/MyDylib"];
    // 载入动态库
    void * p = dlopen([path cStringUsingEncoding:NSUTF8StringEncoding], RTLD_LAZY);
    if (p) {
        // 加载动态库成功 直接使用
        Class cls = NSClassFromString(@"MyObjectOne");
        NSObject *obj = [[cls alloc] init];
        [obj performSelector:@selector(setName:) withObject:@"Hello"];
        [MyLog log:[obj performSelector:@selector(name)]];
    }
}

@end

此时,再次编译运行此工程,如果你观察测试项目的二进制文件,里面的加载命令中已经没有了MyDylib的加载,但是程序依然可以正常的执行,dlopen函数的作用就是在运行时载入动态链接库,载入成功后,我们可以借助OC的运行时方法,直接调用到动态库中的代码。通过这种方式,我们实际上可以实现插件动态下载与使用,使得应用有非常高的热更新能力,但是需要注意,动态下载动态库的方式并不允许在AppStore上架,我们只能在测试的App或企业的App中使用。

再进一步说,其实动态库的读取并不一定是从本地沙盒中,在本地调试时,你可以从任何位置读取动态库文件进行加载,这可以在本地实现很多非常酷的功能,比如Injection工具,它通过一个服务监听代码文件的变化,之后将其打包成动态库注入到程序中,再通过运行时替换类和方法,从而实现本地开发iOS项目的热更新效果,非常好用。

专注技术,懂的热爱,愿意分享,做个朋友

QQ:316045346

展开阅读全文
加载中
点击加入讨论🔥(2) 发布并加入讨论🔥
打赏
2 评论
7 收藏
1
分享
返回顶部
顶部