iOS外掛化架構探索

小顧iOSer發表於2021-08-04

原創:搜狐技術產品

前言

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/framework: dlopen(/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

相關文章