BundleLoader:幫你無縫載入自定義Bundle裡的資原始檔

fletcheryang2014發表於2018-04-01

引子

iOS開發中,我們封裝SDK給第三方使用通常採用.a或.framework + .bundle的形式。相信封裝過這種帶bundle資原始檔的SDK的同學們一定都會遇到這樣一個小麻煩。那就是載入自定義Bundle裡的資源的程式碼寫起來和我們平時開發App時載入mainBundle裡的資源的程式碼是不同的,前者寫起來要麻煩一些。

如果你正在封裝帶資源的SDK,那我相信BundleLoader應該可以幫助到你。它可以幫你消除這種呼叫上的不同,你只需要簡單的呼叫兩個方法就可以像載入App裡的資源那樣『無縫』的載入自定義Bundle裡的資源。既有程式碼無需修改,後續程式碼你也可以繼續用最簡潔最熟悉的方式開發。

專案地址: BundleLoader

問題

最近,本人碰到了這樣一個需求。我是做直播APP的,老闆要求我從APP裡把直播間相關的部分分離出來封裝成SDK給第三方使用,並且今後要做到SDK和APP能夠同步開發,同步更新。

這種情況下,這種呼叫不同對我來說就是個大麻煩了。 其一,直播間及相關部分的程式碼量非常龐大,各種資源各種形式的呼叫,改起來很麻煩。 其二,改動了以後今後同步開發也是個麻煩。

要解決這個問題,我們先來看看程式碼上會有何不同。比如圖片,我們知道載入App主包裡的圖片程式碼只需要簡單的一句:

UIImage *img = [UIImage imageNamed:@"pic"];
複製程式碼

而載入自定義Bundle裡的圖片則要麻煩一些:

NSString *path = [[NSBundle mainBundle] pathForResource:@"myBundle" ofType:@"bundle"];
NSBundle *bundle = [NSBundle bundleWithPath:path];
NSString *file = [bundle pathForResource:@"pic" ofType:@"png"];
UIImage *img = [UIImage imageWithContentsOfFile:file];
複製程式碼

或者簡化一點:

NSString *file2 = [[NSBundle mainBundle] pathForResource:@"myBundle.bundle/pic" ofType:@"png"];
UIImage *img2 = [UIImage imageWithContentsOfFile:file2];
複製程式碼

再簡化一點:

UIImage *img3 = [UIImage imageNamed:@"myBundle.bundle/pic"];
複製程式碼

但是還是都沒有mainBundle裡的簡單。於是,我就想,能不能不改程式碼就可以載入自定義Bundle裡的資源呢?方法肯定有,OC強大的Runtime出馬,沒有搞不定的事情,哈哈。

特性

BundleLoader的Demo裡目前測試了下列幾種情況的自定義bundle資源無縫載入:

  • 圖片
  • xib
  • storyboard
  • xcssets圖片
  • 普通資原始檔

xib或storyboard裡用到的圖片和xcssets圖片也都可以正常顯示。 同時,Demo還提供了一個簡單的Framework + Bundle的工程模版,可以供大家參考。

其他資源,如CoreData模型,本地化字串等應該也可以載入,如果不行的話大家也可以依葫蘆畫瓢,自行實現。

實現

具體的實現其實並不複雜,最關鍵的一點是:我發現,App裡不論載入什麼型別的資源,呼叫什麼介面,系統內部都會去呼叫NSBundle的這個方法:

- (nullable NSString *)pathForResource:(nullable NSString *)name ofType:(nullable NSString *)ext;
複製程式碼

這個方法就是突破口,我們只要在這個方法上去想辦法,做文章,再用上靈活強大的Runtime,應該就能達到我們的目的。

實現的步驟如下:

  • 獲取自定義資源Bundle的物件
  • 把這個物件關聯到mainBundle物件上
  • 把mainBundle物件的Class設為自定義Bundle子類的Class
  • 在Bundle子類裡重寫pathForResource:ofType:方法
  • 這個方法裡拿到關聯的自定義Bundle物件
  • 判斷自定義Bundle物件裡該檔案是否存在,存在則返回其路徑
  • 不存在則去mainBundle裡找

上程式碼:

@implementation BundleLoader

+ (void)initFrameworkBundle:(NSString*)bundleName {
    refCount++;
    NSBundle* bundle = objc_getAssociatedObject(self, NSBundleMainBundleKey);
    if (bundle == nil) {
        //獲取自定義資源Bundle的物件
        NSString *path = [[NSBundle mainBundle] pathForResource:bundleName ofType:@"bundle"];
        NSBundle *resBundle = [NSBundle bundleWithPath:path];
        
        //把這個物件關聯到mainBundle物件上
        objc_setAssociatedObject([NSBundle mainBundle], NSBundleMainBundleKey, resBundle, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        
        //把mainBundle物件的Class設為自定義Bundle子類的Class
        object_setClass([NSBundle mainBundle], [FrameworkBundle class]);
    }
}
複製程式碼
@interface FrameworkBundle : NSBundle

@end

@implementation FrameworkBundle

//系統底層載入圖片,xib都會進這個方法
- (nullable NSString *)pathForResource:(nullable NSString *)name ofType:(nullable NSString *)ext {
    NSBundle* bundle = objc_getAssociatedObject(self, NSBundleMainBundleKey);
    if (bundle) {
        NSString *path = [bundle pathForResource:name ofType:ext];
        if (path)
            return path;
    }
    return [super pathForResource:name ofType:ext];
}
複製程式碼

執行程式碼,發現[UIImage imageNamed:@"crown"]已經可以拿到UIImage物件了。原以為可以打完收工了,結果高興的太早了。如果圖片在xcassets裡,那這樣呼叫還是會失敗。 載入自定義Bundle的xcassets方法只能用下面的方法:

[UIImage imageNamed:name inBundle:bundle compatibleWithTraitCollection:nil];
複製程式碼

繼續折騰,這次該Method Swizzling大法上場了。還不瞭解這個黑魔法的可以看這裡。我們給UImage的imageNamed:方法做了Method Swizzling。程式碼如下:

@implementation UIImage (FrameworkBundle)

#pragma mark - Method swizzling

+ (void)load {
    Method originalMethod = class_getClassMethod([self class], @selector(imageNamed:));
    Method customMethod = class_getClassMethod([self class], @selector(imageNamedCustom:));
    
    //Swizzle methods
    method_exchangeImplementations(originalMethod, customMethod);
}

+ (nullable UIImage *)imageNamedCustom:(NSString *)name {
    //Call original methods
    UIImage *image = [UIImage imageNamedCustom:name];
    if (image != nil)
        return image;
    
    NSBundle* bundle = objc_getAssociatedObject([NSBundle mainBundle], NSBundleMainBundleKey);
    if (bundle)
        return [UIImage imageNamed:name inBundle:bundle compatibleWithTraitCollection:nil];//載入bundle裡xcassets的圖片只能用這個方法
    else
        return nil;
}

@end
複製程式碼

先呼叫imageNamed:獲取圖片,如果拿到則直接返回;失敗則呼叫imageNamed:inBundle:compatibleWithTraitCollection:方法去獲取圖片,並傳入自定義Bundle物件。這樣Bundle裡的xcassets圖片也可以簡單載入了。

至於xib和storyboard也是同樣的做法。

總結

實現還是比較簡單的,用到了三個Runtime方法,分別是:

  1. 關聯物件 objc_setAssociatedObject
  2. 改變物件型別 object_setClass
  3. Method Swizzling method_exchangeImplementations

通過自定義的子類和自定義方法讓系統先從我們的資源Bundle里載入檔案,找不到再去主包里載入。

如果這個庫對你有用,請各位賞個贊吧,謝謝。

相關文章