RN图片加载和原生统一
针对RN和原生混合开发的项目,由于图片的加载RN有自己的一套机制,跟原生的是分开的,就存在加载和缓存的差异性;我们可以做一些工作让图片的加载统一成一套,这对于维护和做一些优化都是有益处的
1. RN图片加载框架
在对RN的图片加载做优化之前,我们得先知道RN的图片加载框架流程;
RN的图片的加载、缓存、解码等都是在RCTImageLoader
中处理的,这里大概梳理了下其中的结构
RCTImageLoader
可以划分为以下几个模块:
- 图片加载 RCTImageURLLoader
- 图片缓存 RCTImageCache
- 图片解码 RCTImageDataDecoder
为了增强模块的可扩展性,RN将这三个核心模块都提供了外部可定制的能力,通过set方法、或者协议的方式;
1.1 图片的缓存
定义了RCTImageCache
协议,同时提供了- (void)setImageCache:(id<RCTImageCache>)cache;
来供外部去定义图片缓存模块
/**
* Provides an interface to use for providing a image caching strategy.
*/
@protocol RCTImageCache <NSObject>
- (UIImage *)imageForUrl:(NSString *)url
size:(CGSize)size
scale:(CGFloat)scale
resizeMode:(RCTResizeMode)resizeMode;
- (void)addImageToCache:(UIImage *)image
URL:(NSString *)url
size:(CGSize)size
scale:(CGFloat)scale
resizeMode:(RCTResizeMode)resizeMode
response:(NSURLResponse *)response;
@end
如果我们设置了自定义的图片缓存,那么就使用自定义的,否则RN内部会使用默认的RCTImageCache
;这个默认的实现只做了内存缓存,没有做磁盘缓存的
// 提供set方法供外部设置图片缓存模块
- (void)setImageCache:(id<RCTImageCache>)cache;
- (void)setImageCache:(id<RCTImageCache>)cache
{
if (_imageCache) {
RCTLogWarn(@"RCTImageCache was already set and has now been overriden.");
}
_imageCache = cache;
}
// 如果设置了图片缓存模块则用外部设置的,否则使用默认实现
- (id<RCTImageCache>)imageCache
{
if (!_imageCache) {
//set up with default cache
_imageCache = [RCTImageCache new];
}
return _imageCache;
}
1.2 图片加载
图片加载的统一入口函数:
- (RCTImageLoaderCancellationBlock)loadImageWithURLRequest:(NSURLRequest *)imageURLRequest
size:(CGSize)size
scale:(CGFloat)scale
clipped:(BOOL)clipped
resizeMode:(RCTResizeMode)resizeMode
progressBlock:(RCTImageLoaderProgressBlock)progressBlock
partialLoadBlock:(RCTImageLoaderPartialLoadBlock)partialLoadBlock
completionBlock:(RCTImageLoaderCompletionBlock)completionBlock;
同时定义了RCTImageURLLoader
协议,将图片的加载能力进行抽象;协议中的canLoadImageURL:
方法用来定义该Loader支持的URL资源的加载,这样不同的图片资源就可以使用不同的Loader去加载
/**
* Provides the interface needed to register an image loader. Image data
* loaders are also bridge modules, so should be registered using
* RCT_EXPORT_MODULE().
*/
@protocol RCTImageURLLoader <RCTBridgeModule>
// 是否是该loader支持加载的图片URL
- (BOOL)canLoadImageURL:(NSURL *)requestURL;
// 图片加载入口函数
- (RCTImageLoaderCancellationBlock)loadImageForURL:(NSURL *)imageURL
size:(CGSize)size
scale:(CGFloat)scale
resizeMode:(RCTResizeMode)resizeMode
progressHandler:(RCTImageLoaderProgressBlock)progressHandler
partialLoadHandler:(RCTImageLoaderPartialLoadBlock)partialLoadHandler
completionHandler:(RCTImageLoaderCompletionBlock)completionHandler;
@optional
// loader 优先级
- (float)loaderPriority;
// 是否需要将任务放到内部的串行队列去执行,默认是YES在主线程执行
- (BOOL)requiresScheduling;
// 是否缓存图片,默认是YES
- (BOOL)shouldCacheLoadedImages;
@end
跟RCTImageCache
不同的是,图片加载loader不是通过提供set接口去定制,而是通过RCT_EXPORT_MODULE()
的方式导出模块给RN,内部去获取实现了RCTImageURLLoader
协议的loaders列表_loaders = [_bridge modulesConformingToProtocol:@protocol(RCTImageURLLoader)]
- (id<RCTImageURLLoader>)imageURLLoaderForURL:(NSURL *)URL
{
if (!_maxConcurrentLoadingTasks) {
[self setUp];
}
if (!_loaders) {
// Get loaders, sorted in reverse priority order (highest priority first)
RCTAssert(_bridge, @"Bridge not set");
_loaders = [[_bridge modulesConformingToProtocol:@protocol(RCTImageURLLoader)] sortedArrayUsingComparator:^NSComparisonResult(id<RCTImageURLLoader> a, id<RCTImageURLLoader> b) {
float priorityA = [a respondsToSelector:@selector(loaderPriority)] ? [a loaderPriority] : 0;
float priorityB = [b respondsToSelector:@selector(loaderPriority)] ? [b loaderPriority] : 0;
if (priorityA > priorityB) {
return NSOrderedAscending;
} else if (priorityA < priorityB) {
return NSOrderedDescending;
} else {
return NSOrderedSame;
}
}];
}
// ...
// Normal code path
for (id<RCTImageURLLoader> loader in _loaders) {
if ([loader canLoadImageURL:URL]) {
return loader;
}
}
return nil;
}
RN图片加载模块默认实现了2类资源的Loader
- RCTPhotoLibraryImageLoader 相册资源
- RCTLocalAssetImageLoader 本地资源
对于网络图片的加载,没有内置的Loader,假如外部没有定义该类型的loader则默认走的RCTNetworking
模块去加载的
我们如果想对于网络资源图片的加载走跟原生一样的模块(比如SDWebImage)则可以通过2种方式去实现:
- 可以通过定义一个Loader并通过
RCT_EXPORT_MODULE()
的方式导出模块给RN就能实现 - hook内部的默认网络资源加载函数
loadImageWithURLRequest:
1.3 图片解码
针对请求返回的是data类型的数据,则需要去将data解码成图片;通过定义了RCTGIFImageDecoder
协议来将解码的能力抽象,外部则可以实现对应的解码器来解码不同类型的数据,这里的设计跟上面介绍的Loader的设计是一样的
/**
* Provides the interface needed to register an image decoder. Image decoders
* are also bridge modules, so should be registered using RCT_EXPORT_MODULE().
*/
@protocol RCTImageDataDecoder <RCTBridgeModule>
// 判断数据是否是该loader可以解码的
- (BOOL)canDecodeImageData:(NSData *)imageData;
// 解码函数,传入imageData解码得到image
- (RCTImageLoaderCancellationBlock)decodeImageData:(NSData *)imageData
size:(CGSize)size
scale:(CGFloat)scale
resizeMode:(RCTResizeMode)resizeMode
completionHandler:(RCTImageLoaderCompletionBlock)completionHandler;
@optional
// 优先级,内部会根据这个来排序,使用优先级高的decoder
- (float)decoderPriority;
@end
RN模块默认内置了一个RCTGIFImageDecoder
gif的解码模块,假如我们需要支持WebP,那么就可以定义一个WebP的Decoder来实现解码
2. RN和原生的图片缓存统一
由于RN和原生的图片加载是2个模块去实现的,这就存在一张图可能RN侧加载缓存了、原生侧也加载缓存了,这就造成了资源的重复加载以及无法复用缓存的问题;
同时RN的缓存模块还只是做了内存缓存的,app杀掉下次打开则还是又会发起网络请求去加载,这就造成了不必要的请求浪费
为了解决这些问题,将两端的图片缓存统一就显得有必要,同时统一了之后,后续需要做修改或者优化则不用2端都去修改,增强了可维护性
2.1 自定义ImageCache
RCTImageLoader
也提供了缓存协议以及设置缓存的函数,自定制起来也很简单,定义一个Cache实现RCTImageCache
协议
@interface HCRNImageCache : NSObject <RCTImageCache>
@end
项目用的是SDWebImage做图片加载的,那么缓存的内部实现就是用SD那一套,只需要将缓存的key跟SD保持一致,那么读取和写入就统一了
@implementation HCRNImageCache
#pragma mark - RCTImageCache
- (void)addImageToCache:(UIImage *)image URL:(NSString *)url size:(CGSize)size scale:(CGFloat)scale resizeMode:(RCTResizeMode)resizeMode response:(NSURLResponse *)response {
NSString *cacheKey = [HCImageLoaderUtility cacheKeyWithUrlString:url size:size scale:scale];
if ([self isURLInBlackList:url]) {
[[SDImageCache sharedImageCache] storeImage:image forKey:cacheKey toDisk:NO completion:nil];
} else {
[[SDImageCache sharedImageCache] storeImage:image forKey:cacheKey toDisk:YES completion:nil];
}
}
- (UIImage *)imageForUrl:(NSString *)url size:(CGSize)size scale:(CGFloat)scale resizeMode:(RCTResizeMode)resizeMode {
NSString *cacheKey = [HCImageLoaderUtility cacheKeyWithUrlString:url size:size scale:scale];
if ([self isURLInBlackList:url]) {
return [[SDImageCache sharedImageCache] imageFromMemoryCacheForKey:cacheKey];
} else {
return [[SDImageCache sharedImageCache] imageFromCacheForKey:cacheKey];
}
}
#pragma mark - Private Methods
// 这里用来判断哪些资源不是网络资源:比如本地资源或者RN调试模式的本地资源
- (BOOL)isURLInBlackList:(NSString *)url {
return [HCImageLoaderUtility isLocalAssetURL:url];
}
@end
这里我将用到的一些工具方法抽到一个工具类中
// 由于有一些是工具方法,就抽出来一个工具类来
@implementation HCImageLoaderUtility
// 根据URL返回缓存的key,内部实现就是SD那一套
+ (NSString *)cacheKeyWithUrlString:(NSString *)urlString size:(CGSize)size scale:(CGFloat)scale {
NSString *cacheKey = [self sdCacheKeyWithUrlString:urlString];
return cacheKey;
}
+ (NSString *)sdCacheKeyWithUrlString:(NSString *)urlString {
NSString *cacheKey = [[SDWebImageManager sharedManager] cacheKeyForURL:[NSURL URLWithString:urlString]];
return cacheKey;
}
// 判断URL是否是本地的资源文件
+ (BOOL)isLocalAssetURL:(NSString *)url {
// 调试RN模式
BOOL isDebugMode = [url rangeOfString:@"http"].location != NSNotFound && [url rangeOfString:@"8081/assets"].location != NSNotFound;
// 加载本地图片文件
BOOL isLocalAsset = [url rangeOfString:@"file://"].location != NSNotFound;
return isDebugMode || isLocalAsset;
}
+ (BOOL)isURLNotSupportFormat:(NSString *)url {
// 非阿里云返回原地址; 动图返回原地址
if (![url containsString:@"oss"] || ![url containsString:@"aliyuncs.com"] || [url containsString:@".gif"]) {
return YES;
}
return NO;
}
+ (BOOL)checkNeedSetSizeCompressFormatWithURLString:(NSString *)url size:(CGSize)size scale:(CGFloat)scale {
CGFloat width = size.width * scale;
CGFloat height = size.height * scale;
BOOL willSetSize = YES;
if (width <= 0 || width > 4096 || height <= 0 || height > 4096) {
willSetSize = NO;
}
return willSetSize;
}
@end
2.2 注入自定义ImageCache到RN模块
注入则需要注意注入的时机,以及当bridge reload之后需要重新注入(reload之后会重新加载RN模块,RN模块load完成会有一个通知RCTJavaScriptDidLoadNotification
);这里我们使用一个管理类来处理注入的逻辑,监听这个RN模块load完成的通知,然后去设置自定义的ImageCache即可
@interface HCRNImageLoader ()
@property (nonatomic, nullable, strong) HCRNImageCache *imageCache;
@end
@implementation HCRNImageLoader
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
+ (instancetype)sharedInstance {
static HCRNImageLoader *_instance;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_instance = [self new];
});
return _instance;
}
- (void)startObserver {
[self addNotifications];
}
- (void)registerHCImageLoader {
// 你的项目的RCTbridge实例
SomeBridge.imageLoader setImageCache:self.imageCache];
}
- (void)unregisterHCImageLoader {
self.imageCache = nil;
SomeBridge.imageLoader setImageCache:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Private Methods
- (void)addNotifications {
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(jsBridgeReloaded) name:RCTJavaScriptDidLoadNotification object:nil];
}
- (void)jsBridgeReloaded {
[self registerHCImageLoader];
}
#pragma mark - Getter && Setter
- (HCRNImageCache *)imageCache {
if (_imageCache == nil) {
_imageCache = [HCRNImageCache new];
}
return _imageCache;
}
@end
3. RN和原生的图片加载统一
图片的加载统一上面也介绍了有2种方式可以实现,自定义RCTImageURLLoader
或者hook RN的图片加载入口函数loadImageWithURLRequest:
下面介绍定义loader的方式:
@implementation HCRNURLImageLoader
RCT_EXPORT_MODULE()
- (BOOL)canLoadImageURL:(NSURL *)requestURL {
// 这里的逻辑实现根据项目的图片资源的URL格式来实现即可
return (!requestURL.isFileURL && [requestURL.absoluteString containsString:@"https://"]);
}
- (BOOL)shouldCacheLoadedImages {
return YES;
}
- (RCTImageLoaderCancellationBlock)loadImageForURL:(NSURL *)imageURL size:(CGSize)size scale:(CGFloat)scale resizeMode:(RCTResizeMode)resizeMode progressHandler:(RCTImageLoaderProgressBlock)progressHandler partialLoadHandler:(RCTImageLoaderPartialLoadBlock)partialLoadHandler completionHandler:(RCTImageLoaderCompletionBlock)completionHandler {
// 取缓存
NSString *cacheKey = [HCRNImageLoaderUtility cacheKeyWithUrlString:imageURL.absoluteString size:size scale:scale];
UIImage *cachedImage = [[SDImageCache sharedImageCache] imageFromCacheForKey:cacheKey];
if (cachedImage) {
if (completionHandler) {
completionHandler(nil, cachedImage);
}
} else {
// 取网络
[[SDWebImageDownloader sharedDownloader] downloadImageWithURL:imageURL
options:0
progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
if (progressHandler) {
progressHandler(receivedSize, expectedSize);
}
} completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, BOOL finished) {
if (image) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[[SDImageCache sharedImageCache] storeImage:image forKey:cacheKey completion:nil];
});
}
if (completionHandler) {
completionHandler(error, image);
}
}];
}
return ^{};
}
需要注意的是如果自己实现了Loader,则对应资源的加载直接托管给Loader去实现了,我们需要在loader中处理缓存、解码等这一套流程
RCTImageLoader代码片段截取
if (loadHandler) {
cancelLoad = [loadHandler loadImageForURL:request.URL
size:size
scale:scale
resizeMode:resizeMode
progressHandler:progressHandler
partialLoadHandler:partialLoadHandler
completionHandler:^(NSError *error, UIImage *image) {
completionHandler(error, image, nil);
}];
} else {
UIImage *image;
if (cacheResult) {
image = [[strongSelf imageCache] imageForUrl:request.URL.absoluteString
size:size
scale:scale
resizeMode:resizeMode];
}
if (image) {
completionHandler(nil, image, nil);
} else {
// Use networking module to load image
cancelLoad = [strongSelf _loadURLRequest:request
progressBlock:progressHandler
completionBlock:completionHandler];
}
}
你可以选择这种方式定义一个Loader去处理,也可以不做处理让走默认的图片加载流程
4. 图片加载的一些优化
图片加载优化除了缓存之外,还包括按需加载(按视图尺寸加载)、压缩(压缩参数、WebP格式等等)、裁剪(圆角)等等,现在主流的文件托管平台都支持通过配置参数来获取定制化的图片资源
这些通过定制URL的图片格式化参数来加载图片带来的益处是值得去做的
- 按视图尺寸加载 -- 减少了视图渲染的
Color Misaligned Images
- 压缩参数 -- 降低了图片资源的大小,提升了下载的效率
- 裁剪等参数 -- 不需要代码去处理特殊的效果,通常一些效果还会触发离屏渲染
项目中图片是放在阿里云OSS上,我们可以根据文档来设置这些参数图片缩放
那么想让RN的图片加载也可以去设置图片处理的参数,当然也可以在自定义的Loader中去处理URL拼接format参数,或者不自定义Loader的话直接hook RN图片加载的入口函数loadImageWithURLRequest:
@implementation RCTImageLoader (HCLoader)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleInstanceMethod:@selector(loadImageWithURLRequest:size:scale:clipped:resizeMode:progressBlock:partialLoadBlock:completionBlock:) with:@selector(hc_loadImageWithURLRequest:size:scale:clipped:resizeMode:progressBlock:partialLoadBlock:completionBlock:)];
});
}
- (RCTImageLoaderCancellationBlock)hc_loadImageWithURLRequest:(NSURLRequest *)imageURLRequest
size:(CGSize)size
scale:(CGFloat)scale
clipped:(BOOL)clipped
resizeMode:(RCTResizeMode)resizeMode
progressBlock:(RCTImageLoaderProgressBlock)progressBlock
partialLoadBlock:(RCTImageLoaderPartialLoadBlock)partialLoadBlock
completionBlock:(RCTImageLoaderCompletionBlock)completionBlock {
NSString *formattedURLString = [self formatURLWithURLString:imageURLRequest.URL.absoluteString size:size scale:scale mode:resizeMode];
NSMutableURLRequest *tmpURLRequest = imageURLRequest.mutableCopy;
tmpURLRequest.URL = [NSURL URLWithString:formattedURLString];
return [self hc_loadImageWithURLRequest:tmpURLRequest
size:size
scale:scale
clipped:clipped
resizeMode:resizeMode
progressBlock:progressBlock
partialLoadBlock:partialLoadBlock
completionBlock:completionBlock];
}
- (NSString *)formatURLWithURLString:(NSString *)urlString size:(CGSize)size scale:(CGFloat)scale mode:(RCTResizeMode)resizeMode {
BOOL isInBlackList = [self isURLInBlackList:urlString];
if (isInBlackList) { // 不需要拼接参数的直接返回原URL
return urlString;
}
// 根据传入的width、height、scale、resizeMode来拼接url的format参数,格式类似这样 ?x-oss-process=image/resize,m_lfit,w_148,h_148/format,webp
BOOL needSetSizeFormat = [HCImageLoaderUtility checkNeedSetSizeCompressFormatWithURLString:urlString size:size scale:scale];
// ?x-oss-process=image/resize,m_lfit,w_148,h_148/format,webp
NSString *resizeModeString = @"lfit"; // oss默认值
switch (resizeMode) {
case RCTResizeModeCover: // UIViewContentModeScaleAspectFill
resizeModeString = @"fill";
break;
case RCTResizeModeContain: // UIViewContentModeScaleAspectFit
resizeModeString = @"pad";
break;
case RCTResizeModeStretch: // UIViewContentModeScaleToFill
resizeModeString = @"fixed";
break;
case RCTResizeModeCenter: // UIViewContentModeCenter
resizeModeString = @"fill";
break;
default:
resizeModeString = @"lfit";
break;
}
NSMutableString *formattedString = urlString.mutableCopy;
// 缩放配置
[formattedString appendFormat:@"?x-oss-process=image/resize,m_%@", resizeModeString];
if (needSetSizeFormat) {
// 宽高设置
[formattedString appendFormat:@",w_%.0f,h_%.0f", ceil(size.width * scale), ceil(size.height * scale)];
}
// WebP格式设置
[formattedString appendFormat:@"/format,webp"];
return formattedString;
}
// 这里根据需求,将不支持参数的URL、或者不需要拼接参数的URL过滤掉
- (BOOL)isURLInBlackList:(NSString *)url {
BOOL isLocalAsset = [HCImageLoaderUtility isLocalAssetURL:url];
BOOL isNotSupportFormat = [HCImageLoaderUtility isURLNotSupportFormat:url];
return isLocalAsset || isNotSupportFormat;
}
@end
我们设置了图片格式WebP,那么默认的RCTImageLoader是么有WebP格式的Decoder,此时就需要实现一个供其使用,定义一个实现协议RCTImageDataDecoder
的解码器
@interface HCRNWebPImageDecoder : NSObject <RCTImageDataDecoder>
@end
@implementation HCRNWebPImageDecoder
RCT_EXPORT_MODULE()
- (BOOL)canDecodeImageData:(NSData *)imageData {
return [[SDWebImageWebPCoder sharedCoder] canDecodeFromData:imageData];
}
- (RCTImageLoaderCancellationBlock)decodeImageData:(NSData *)imageData
size:(CGSize)size
scale:(CGFloat)scale
resizeMode:(RCTResizeMode)resizeMode
completionHandler:(RCTImageLoaderCompletionBlock)completionHandler {
UIImage *image = [[SDWebImageWebPCoder sharedCoder] decodedImageWithData:imageData];
if (completionHandler) {
if (image) {
completionHandler(nil, image);
} else {
completionHandler([NSError errorWithDomain:NSCocoaErrorDomain code:0 userInfo:@{NSLocalizedFailureReasonErrorKey : @"解码失败"}], nil);
}
}
return ^{};
}
@end
至此RN的图片加载就也支持设置format参数获取处理过的图片,同时也支持WebP格式的图片的加载了
5. 总结
在做RN的图片加载模块优化的时候,阅读了RCTImageLoader的源码,也学到了一些设计的理念,模块划分很清晰:Cache、Loader、Decoder;同时也提供了接口或者协议的方式将能力抽象,供外部可定制化。这样在做定制的时候就很清晰,而不用去各种hook方法去实现。