BundleLoader:帮你无缝加载自定义Bundle里的资源文件

5,582 阅读5分钟

引子

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里加载文件,找不到再去主包里加载。

如果这个库对你有用,请各位赏个赞吧,谢谢。