iOS插件化架构探索

2,084 阅读9分钟

原创:搜狐技术产品

前言

WWDC2014苹果在iOS上开放了动态库,这给了我们一个很大的想象空间。

动态库即动态链接库,是Cocoa/Cocoa Touch程序中使用的一种资源打包方式,可以将代码文件、头文件、资源文件、说明文档等集中在一起,方便开发者使用。动态库在编译时并不会被拷贝到程序的可执行文件(也就是mach-o)中,等到程序运行时,动态库才会被真正加载。

动态库运行时才载入的特性,也可以让我们随时对库进行替换,而不需要重新编译代码。这样我们就可以做很多事情,比如应用插件化及动态更新:Github

  • 应用插件化

    目前很多应用功能越做越多,软件显得越来越臃肿,如果软件的功能模块也能像懒加载那样按需加载,在用户想使用某个功能的时候让其从网络下载,然后手动加载动态库,实现功能的插件化,就再也不用担心功能点的无限增多了,这该是件多么美好的事!

  • 应用模块动态更新

    当软件中的某个功能点出现了严重的 bug,或者想更新某个功能,这时候只需要在适当的时候从服务器上将新版本的动态库文件下载到本地,然后在用户重启应用的时候即可实现新功能的展现。

下面将具体介绍如何使用动态 Framework的方式实现App的插件化及动态更新:

实现思路

将 App中的某个模块的内容独立成一个动态Framework的形式,在用户想使用某个功能的时候,根据配置列表从服务器上将对应的动态库文件下载到沙盒,然后加载动态库并由principalClass进入独立功能模块,实现功能的插件化动态加载。并根据配置列表的版本号,对已下载的动态库进行比对更新,即可达到动态更新的目的。

用户点击某个模块再下载的话,会有明显的等待过程,为了有更好的用户体验,可以选择预加载策略,或在项目中配置默认动态库,这部分可以根据项目的实际情况来选择,这里暂不展开讨论。

下图是整体的实现流程:

image.png 项目搭建

项目实现主要分为两部分:1、创建动态库;2、主App加载维护动态库。这里把项目搭建拆分细化为四个部分,分别是动态加载框架SVPCore和SVPRuntime、主工程以及其他功能模块插件,整体的架构设计如下图:

image.png

1. SVPCore

SVPCore的主要作用是对配置信息进行解析,查找到对应的bundle对象,并获取插件的主入口。包含SVPURI、SVPDispatch类及一个SVPBundleDelegate的协议。

SVPURI: 提供了一个静态初始化方法,在初始化时对传入的地址进行解析,分别将scheme(动态库协议名)、parameters(动态库初始化参数)及resourcePath(动态库路径)解析出来并存储;

SVPDispatch: 提供了一个SVPBundleProvider的协议用于获取将要加载的bundle对象,然后通过SVPBundleDelegate协议提供的resourceWithURI:方法获取加载好的插件主入口对象。

SVPBundleDelegate: 提供了一个根据SVPURI获取UIViewController的协议,由插件动态库的principalClass实现该协议,返回插件的主入口对象。同时,可以将主工程配置信息里的参数,通过SVPURI的parameters的形式传递给主入口对象,当插件动态库提供给多个工程使用时,可以方便灵活的实现自定义初始化。

SVPURI的主要代码如下:

- (id)initWithURIString:(NSString *)uriString{    self = [super init];    if (self)    {        _uriString = [uriString copy];        NSURL *url = [NSURL URLWithString:_uriString];        if (!url || !url.scheme) return nil;            	// scheme用来标记动态库协议名        _scheme = url.scheme;        NSRange pathRange = NSMakeRange(_scheme.length + 3, _uriString.length - _scheme.length - 3);        if (url.query)        {            NSArray *components = [url.query componentsSeparatedByString:@"&"];            NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithCapacity:0];            for (NSString *item in components)            {                NSArray *subItems = [item componentsSeparatedByString:@"="];                if (subItems.count >= 2)                {                    parameters[subItems[0]] = subItems[1];                }            }          	// parameters用来标记动态库初始化参数            _parameters = parameters;            pathRange.length -= (url.query.length + 1);        }        if (pathRange.length > 0 && pathRange.location < uriString.length)        {          	// resourcePath用来标记动态库路径            _resourcePath = [_uriString substringWithRange:pathRange];        }    }    return self;}

SVPDispatch主要代码如下:

// 根据URI获取动态库主入口页面- (id)resourceWithURI:(NSString *)uriString{    if (!uriString || !_bundleProvider) return nil;    return [self resourceWithObject:[SVPURI URIWithString:uriString]];}- (id)resourceWithObject:(SVPURI *)uri{    if (!uri) return nil;    id resource = nil;  	// bundleProvider为SVPRuntime,其实现代理方法返回URI对应的动态库的principalObject    if (_bundleProvider && [_bundleProvider respondsToSelector:@selector(bundleDelegateWithURI:)])    {        id<SVPBundleDelegate> delegate = [_bundleProvider bundleDelegateWithURI:uri];            	// delegate为动态库的principalObject,其实现代理方法返回动态库的主入口页面        if (delegate && [delegate respondsToSelector:@selector(resourceWithURI:)])        {            resource = [delegate resourceWithURI:uri];        }    }    return resource;}

2. SVPRuntime

SVPRuntime的主要作用是对功能模块插件进行管理,包括下载/解压插件以及读取解压后插件的动态库等。包含SVPBundle、SVPBundleDownloadItem类及SVPBundleManager管理类。

SVPBundle: 提供了一个通过bundlePath来初始化的方法,并提供了一个load方法,从沙盒中将动态库读取到bundle对象并加载,加载完成后获取bundle的principalClass对象并初始化,拿到插件模块入口;

SVPBundleDownloadItem: 提供了一个通过配置信息来初始化的方法,根据配置信息里的远程地址对插件进行下载,下载成功后根据配置信息里的唯一标识、版本号、动态库名称等将动态库解压到对应的目录;

SVPBundleManager: 实现SVPCore提供的SVPBundleProvider协议,将下载、解压并加载好的插件入口提供给SVPCore。初始化后读取本地已下载好的bundles列表,若用户点击了某个功能模块则先从列表中查看该插件是否已安装,若未安装则初始化一个SVPBundleDownloadItem,然后调用Item的下载方法,之后在下载回调里将下载好的动态库解压并初始化其对应的bundle。

在这里需要注意两点:

一是没有采用普遍的Class loadClass = [bundleclassNamed:className];的形式获取插件主入口对象,因为这种实现方式必须提前知道插件主入口的className,而且不能自定义初始化参数,因此设计为更为灵活的通过SVPDispatch统一调度中转的方式来实现:通过SVPDispatch的resourceWithURI:方法,将SVPURI里的parameters初始化参数传递给插件主入口对象,由主入口对象进行主页面的初始化并返回。

二是为了实现动态库的版本比对和动态更新,在存储时需记录动态库的版本号,并且在更新后删除之前的旧版本数据。

SVPBundle的主要代码如下:

- (BOOL)load{    if (self.status == SVPBundleLoaded) return YES;    self.status = SVPBundleLoading;  	// 使用路径获取一个NSBundle对象    self.bundle = [NSBundle bundleWithPath:self.bundlePath];    NSError *error = nil;    if (![self.bundle preflightAndReturnError:&error])    {        NSLog(@"%@", error);    }  	// 加载NSBundle    if (self.bundle && [self.bundle load])    {        self.status = SVPBundleLoaded;      	// 获取NSBundle的principalObject        self.principalObject = [[[self.bundle principalClass] alloc] init];        if (self.principalObject && [self.principalObject respondsToSelector:@selector(bundleDidLoad)])        {            [self.principalObject performSelector:@selector(bundleDidLoad)];        }    }    else    {        self.status = SVPBundleLoadFailed;    }    return self.status == SVPBundleLoaded;}

SVPBundleManager主要代码如下:

- (instancetype)init {    self = [super init];    if (self) {      	// 遵循SVPCore的协议        [SVPAccessor defaultAccessor].bundleProvider = self;              	// 遍历本地文件夹,加载动态库        _installedBundles = [NSMutableDictionary dictionary];        NSString *mainPath = [self bundleFolder];        NSDirectoryEnumerator *directoryEnumerator = [self.fileManager enumeratorAtPath:mainPath];        for (NSString *path in directoryEnumerator.allObjects) {            NSString *subPath = [mainPath stringByAppendingPathComponent:path];            NSArray *dirArray = [self.fileManager contentsOfDirectoryAtPath:subPath error:nil];            if (dirArray.count > 0) {                NSString *frameworkName = [dirArray firstObject];                if ([frameworkName hasSuffix:@".framework"]) {                    NSString *bundlePath = [subPath stringByAppendingPathComponent:frameworkName];                    SVPBundle *bundle = [[SVPBundle alloc] initWithBundlePath:bundlePath];                                        NSString *version = @"";                    NSArray *strArray = [frameworkName componentsSeparatedByString:@"_"];                    if (strArray.count > 0) {                        version = [strArray firstObject];                    }                  	// 动态库标识:版本号+唯一标识                    NSString *bundleKey = [NSString stringWithFormat:@"%@_%@", version, path];                    _installedBundles[bundleKey] = bundle;                }            }        }    }    return self;}#pragma mark - SVPBundleDownloadItemDelegate// 下载完成,解压下载下来的动态库- (void)downloadBundleItem:(SVPBundleDownloadItem *)downloadItem finished:(BOOL)success {    if (success) {        [self unZipDownloadItem:downloadItem];    } else {        if (self.finishBlock) {            self.finishBlock(NO);            self.finishBlock = nil;        }    }}#pragma mark - SVPBundleProviderDelegate// 实现SVPCore的协议,返回URI对应的动态库的principalObject- (id)bundleDelegateWithURI:(SVPURI *)uri {    if ([uri.scheme isEqual:@"scheme"] && uri.resourcePath.length > 0) {        SVPBundle *bundle = _installedBundles[uri.resourcePath];        if (bundle) {            return bundle.principalObject;        }    }    return nil;}

3. 插件模块

首先创建一个动态库,在创建工程时选Cocoa Touch Framework,如下图:

image.png

接下来将SVPCore动态库导入后,创建一个BundleDelegate实现SVPCore的SVPBundleDelegate协议,代码如下:

// 动态库实现SVPCore的协议,返回动态库的主入口页面- (UIViewController *)resourceWithURI:(SVPURI *)uri {    if ([uri.scheme isEqual:@"scheme"]) {        if ([uri.resourcePath isEqualToString:@"wechat"]) {            SVPWechatViewController *wechatVC = [[SVPWechatViewController alloc] initWithParameters:uri.parameters];            return wechatVC;        }    }        return nil;}

SVPWechatViewController,就是该插件的主入口对象,在此基础上实现插件的独立功能就可以了。

然后,最重要的一步,需要在该动态库的Info.plist文件配置Principal class,这个条目的作用是通过NSBundle的principalClass获取到该对象,如下图将SVPWechatBundleDelegate设置进去之后,加载完成后的Bundle发送principalClass消息,拿到的就是这个对象。由于SVPWechatBundleDelegate实现了SVPBundleDelegate协议的resourceWithURI:方法,就可以将插件的入口控制器返回给调用方。

image.png

之后将该插件的动态库编译后打成压缩包,放到服务器上提供下载链接即可。

4. 主工程

主工程的功能相对简单,先从Plist文件中读取配置信息并展示(该Plist文件可从网络下载):

image.png

当用户点击图标时先获取图标信息并查看该插件动态库是否已加载,若未加载则调用SVPBundleManager的downloadItem方法进行下载,若已加载则调用SVPDispatch的resourceWithURI:方法获取插件入口,进行接下来的操作,主要代码如下:

// 用户点击插件- (void)onItemView:(UIButton *)sender {    NSInteger itemIndex = sender.tag - 1000;    if (itemIndex >= 0 && itemIndex < self.pluginArray.count) {      	// 点击的插件对应的配置列表信息        PluginItem *pluginItem = [self.pluginArray objectAtIndex:itemIndex];				      	// 动态库标识:版本号+唯一标识,以实现动态更新的目的        NSString *bundleKey = [NSString stringWithFormat:@"%@_%@", pluginItem.version, pluginItem.identifier];        if (![[SVPBundleManager defaultManager] isInstalledBundleWithBundleKey:bundleKey])        {          	// 本地未加载,先从服务器下载动态库            __weak __typeof(self)weakSelf = self;            __weak __typeof(PluginItem *)weakItem = pluginItem;            __weak __typeof(UIButton *)weakSender = sender;            [[SVPBundleManager defaultManager] downloadItem:[pluginItem toJSONDictionary] finished:^(BOOL success) {                __strong __typeof(weakSelf)strongSelf = weakSelf;                __strong __typeof(weakItem)strongItem = weakItem;                __strong __typeof(weakSender)strongSender = weakSender;                if (success) {                    dispatch_sync(dispatch_get_main_queue(), ^{                        [strongSelf pushBundleVC:itemIndex];                    });                } else {                    // 提示下载失败                }                dispatch_sync(dispatch_get_main_queue(), ^{                    [strongSender setTitle:strongItem.name forState:UIControlStateNormal];                });            }];            [sender setTitle:@"下载中..." forState:UIControlStateNormal];        }        else        {          	// 本地已加载,push动态库的主入口页面            [self pushBundleVC:itemIndex];        }    }}- (void)pushBundleVC:(NSInteger)index {    if (index >= 0 && index < self.pluginArray.count) {        PluginItem *pluginItem = [self.pluginArray objectAtIndex:index];        NSString *uriString = [NSString stringWithFormat:@"scheme://%@_%@", pluginItem.version, pluginItem.resource];        UIViewController *vc = [[SVPAccessor defaultAccessor] resourceWithURI:uriString];        if (vc)        {            [self.navigationController pushViewController:vc animated:YES];        }    }}

当插件模块需要更新时,只需要修改服务器上的配置列表和插件动态库压缩包,主工程在适当的时机更新本地配置列表,当用户点击该插件功能时,即可根据版本号查找并更新本地动态库,达到动态更新的目的。

注意事项

系统在加载动态库时,会检查Framework的签名,签名中必须包含TeamIdentifier,并且Framework和主App的TeamIdentifier必须一致。

如果不一致,会报下面的错误:

Error loading /path/to/frameworkdlopen(/path/to/framework, 265): no suitable image found. Did find:/path/to/framework:code signature in (/path/to/framework) not valid for use in process using Library Validation: mapped file has no cdhash, completely unsigned? Code has to be at least ad-hoc signed.

总结

以上便是利用Framework动态库进行插件化加载以及动态更新的所有实现,就目前而言,Apple并不希望开发者绕过App Store来更新App,因此需谨慎对待热更新的使用,对于不需要上架的企业级应用,是可以使用的。随着苹果开放环境的不断发展,苹果会不会给我们开发者惊喜呢,这就不得而知了。

收录于:Github