雲音樂iOS端網路圖片下載優化實踐
圖片來自:http://unsplash.com
本文作者: lgq
背景
圖片展示,在各大APP中不可或缺,眾所周知雲音樂是一款帶有社交屬性的音樂軟體,那麼在任何社交場景,都會有展示圖片的訴求,並且常常會有重圖片場景,比如一個雲音樂中Mlog的Feed流場景全都是圖片,或者就是Mlog中的圖集,都需要展示大量的圖片,要是圖片無法及時的展示出來,不能及時的被使用者消費,那麼會造成使用者瀏覽資訊不順暢,導致使用者的流失,因此優化圖片下載迫在眉睫。
現有圖片下載技術
這裡簡單瞭解下雲音樂APP中接入的圖片資源服務,它可以通過拼接引數,在遠端進行裁剪,質量壓縮從而下載到不同的圖片。更多資訊參考
影響圖片下載的因素
- 圖片大小
- 網路情況
- 本地快取
- cdn快取
綜上所述,如何提高圖片的下載速度可以從上面幾點開始優化。
優化方式
網路優化
- 傳統 HTTP1.0 的架構下沒法多路複用,採用 HTTP2.0 的方式,請求同一ip域名的資源可以從節省大量建連及傳輸時間。
- 除此之外筆者在做音視訊場景較重的頁面時,發現音視訊流媒體的資料有時候會搶佔大量頻寬,導致圖片下載非常的慢,這時需要對音視訊場景資源下載做適當的控制,如限流等操作,具體看業務優先順序。如音視訊場景使用socket下載時可以適當調中recv buffer 大小。
圖片大小優化
- 格式優化 這是最容易想的到的也是最有效的,如果正常使用jpg,png等常規圖片,那圖片的大小會是比較大的,目前我們的nos服務支援指定型別,將圖片轉成特定的格式,所以我們這裡使用webp,從而減少圖片的大小。(只需要在請求引數中拼接型別為webp即可)
那除此之外呢,我們還可以做一些什麼?
-
按需裁剪 比如一個 100 * 100 的控制元件,3 倍屏的情況下,我們只需要下載 300 * 300 的圖就可以了,如果圖片超過個尺寸,我們去下載那麼大的也沒有意義。所以根據控制元件大小,可以決定我們下的圖片大小,從而減小我們所需下載的圖片。
-
壓縮質量 比如要求沒有那麼高的場景我們只需要質量為 80 的圖就可以了。
思考 以上幾項做完,我們可以發現速度至少提升 30%,但是是不是可以做的更多,或者這個方案有什麼紕漏?
取證 為此我們簡單的拉取了一下後臺資料。發現有以下問題:
- URL拼接的引數不同,導致無法命中本地快取,這樣會有重複下載的問題,比如使用者頭像,使用者頭像再各個場景重複出現,而且大小不一,會下載多次這樣會導致一定的資源浪費。同時由於連結引數各異 cdn命中度不高
- 不同機型的UI尺寸大小可能不太一致,導致下載的片尺寸會不一樣,機型種類越多,拼接的尺寸情況也越多,服務端需要重複裁剪。
- 質量引數由上層業務自行決定,會導致不同端沒有約定好,下載到各式各樣的圖片
解決手段
- URL 引數標準化
所謂的標準化是規範大前端使用的引數拼接,分為順序標準化,引數值擬合。
我們知道一個下載圖片的URL連結
http://path?imageView=1&enlarge=1&quality=80&thumbnail=80x80&type=webp
。 - 其中引數我們按首字母排序,這樣在引數要求一致的情況下,不會出現重複請求。
- thumbnail 引數其實對應的是需要下載的圖片大小,我們做擬合(根據後端統計的到的資料),分成多檔(檔位可以配置),按照寬邊對其等比例縮放,這樣可以儘可能少的避免機型螢幕差一點點,出現了其他size的case。
- quality也同樣分級,分成多檔(檔位可以配置)。
-
去重,引數可能多拼接,對冗餘引數去重複
-
本地大小圖片重用 簡單理解是本地有大圖,取小圖的時候無需額外網路請求,直接本地裁剪。 我們優化了讀取本地快取的邏輯,在取快取的時候,我們會進行關聯查詢,找到可用的圖片進行裁剪,直接返回。 具體規則如下:
- 不同裁剪引數可以轉化,x,z裁剪引數可以轉為y,y不可以轉x,z。都可以轉為相同的裁剪引數。其中x(內縮略),y(裁剪縮略),z(外縮略)的含義在本篇文件中有,代表著不同的填充模式。
- 質量高的圖片可以複用為質量低的圖片,質量低的圖片不可以複用為質量高的圖片
iOS 程式碼實現
說完了方案之後,我們可以上程式碼了,這裡是 iOS的實現方案:
首先我們是基於SDWebImage進行一定的封裝,先簡單瞭解下SDWebImage中大概的流程。
從圖中我們可以看出,下載圖片主要是使用了imageLoader,查詢快取這裡是用了imageCache,這兩個都在manager中被管理
改造流程
我們只需要在資料流轉的最開始對URL進行Fix,同時在查詢快取的時候對圖片增加額外查詢即可。
URL FIX
我們給URL增加一個分類,對URL進行一個fix操作,方案就是用系統提供的 NSURLComponts
對齊進行操作,提取出他的引數,進行去重,標準化,同時我們有一些歷史原因,一些老的引數將其轉為正確的格式,最後一步進行排序,fix流程就完成了。
``` - (NSURL *)demo_fixImageURL {
NSURLComponts *componts = [NSURLComponts compontsWithURL:self
resolvingAgainstBaseURL:YES];
NSMutableArray<NSURLQueryItem *> *queryItems = componts.queryItems.mutableCopy;
... 從URL取出 NSURLQueryItem 省略一些程式碼
if (qualityItem) {
//quality 擬合, 將質量引數分為幾檔
NSString *defaultQualityStr = @"39,69,89";
//這裡是虛擬碼,就是為了獲取配置資訊
NSArray<NSString *> *qualityLevel = CustomConfigQualityLevels;
//固定 4檔
if (qualityLevel.count == 3) {
NSInteger quality = [qualityItem.value intValue];
NSString *fixQuality = @"";
if (quality <= [[qualityLevel _objectAtIndex:0] intValue]) {
fixQuality = [@(ImageQualityLevelLow) stringValue];
} else if (quality <= [[qualityLevel _objectAtIndex:1] intValue]) {
fixQuality = [@(ImageQualityLevelMed) stringValue];
} else if (quality <= [[qualityLevel _objectAtIndex:2] intValue]) {
fixQuality = [@(ImageQualityLevelHigh) stringValue];
} else {
fixQuality = [@(ImageQualityLevelOrigin) stringValue];
}
NSURLQueryItem *fixQualityItem = [[NSURLQueryItem alloc] initWithName:@"quality" value:fixQuality];
[queryItems removeObject:qualityItem];
[queryItems addObject:fixQualityItem];
}
}
if (sizeItem) {
//size 按照寬邊擬合 分為幾檔且 等比縮放
NSString *defaultSizeStr = @"30,60,90,120,180,256,315,512,720,1024";
//這裡是虛擬碼 就是為了獲取配置資訊
NSArray<NSString *> *sizeLevels = CustomConfigSizeLevels;
NSString *originSizeStr = sizeItem.value;
CGSize originSize = CGSizeZero;
NSString *separatedStr = nil;
for (NSString *separated in @[@"x", @"z", @"y"]) {
NSArray *sizeList = [originSizeStr compontsSeparatedByString:separated];
if (sizeList.count == 2) {
originSize = CGSizeMake([sizeList[0] intValue], [sizeList[1] intValue]);
separatedStr = separated;
break;
}
}
CGSize finalSize = CGSizeZero;
if (!CGSizeEqualToSize(originSize, CGSizeZero)) {
BOOL isW = originSize.width > originSize.height;
NSInteger len = isW ? originSize.width : originSize.height;
NSInteger requestSize = 0;
for (NSString *sizeLevel in sizeLevels) {
NSInteger sizeNumber = [sizeLevel integerValue];
if (sizeNumber >= len) {
if (requestSize == 0) {
requestSize = sizeNumber;
} else {
requestSize = MIN(requestSize, sizeNumber);
}
}
}
if (isW) {
if (originSize.width != 0) {
NSInteger h = (requestSize / (originSize.width * 1.f)) * originSize.height;
finalSize = CGSizeMake(requestSize, floor(h));
}
} else {
if (originSize.height != 0) {
NSInteger w = (requestSize / (originSize.height * 1.f)) * originSize.width;
finalSize = CGSizeMake(w, floor(requestSize));
}
}
}
if (!CGSizeEqualToSize(finalSize, CGSizeZero)) {
NSString *fixSize = [NSString stringWithFormat:@"%ld%@%ld",(NSInteger)finalSize.width, separatedStr, (NSInteger)finalSize.height];
NSURLQueryItem *fixSizeItem = [[NSURLQueryItem alloc] initWithName:@"thumbnail" value:fixSize];
[queryItems removeObject:sizeItem];
[queryItems addObject:fixSizeItem];
}
}
//去重複
NSMutableArray<NSString *> *keys = @[].mutableCopy;
queryItems = [queryItems bk_select:^BOOL(NSURLQueryItem *obj) {
BOOL containsObject = [keys containsObject:obj.name];
[keys addObject:obj.name];
return !containsObject;
}].mutableCopy;
//首字母排序
queryItems = [queryItems sortedArrayUsingComparator:^NSComparisonResult(NSURLQueryItem *obj1, NSURLQueryItem *obj2) {
return [obj1.name compare:obj2.name options:NSCaseInsensitiveSearch];
}].mutableCopy;
//最終組合
componts.queryItems = queryItems.copy;
NSURL *finalURL = componts.URL;
return finalURL;
}
```
SDWebImageManager
修復了URL之後,下一步要做什麼,如何將修復後的URL傳遞下去呢?也可以從上面的SDWebImage流程中看出,所有的圖片下載流程,離不開SDWebImageManager,所以我們繼承 SDWebImageManager
,重寫以下方法
- (SDWebImageCombidOperation *)loadImageWithURL:(nullable NSURL *)url
options:(SDWebImageOptions)options
context:(nullable SDWebImageContext *)context
progress:(nullable SDImageLoaderProgressBlock)progressBlock
completed:(nonnull SDInternalCompletionBlock)completedBlock
後續如果要走修復流程的只需要用我們封裝好的manager即可,實現如果下
``` - (SDWebImageCombidOperation )loadImageWithURL:(nullable NSURL )url options:(SDWebImageOptions)options context:(nullable SDWebImageContext *)context progress:(nullable SDImageLoaderProgressBlock)progressBlock completed:(nonnull SDInternalCompletionBlock)completedBlock corp:(BOOL)corp {
NSURL *fixURL = [self.class fixURLWithUrl:url];
SDInternalCompletionBlock fixBlock = completedBlock;
if (![fixURL.absoluteString isEqualToString:url.absoluteString] && corp) {
fixBlock = [self.class fixcompletedBlockWithOriginCompletedBlock:completedBlock url:url];
}
return [super loadImageWithURL:fixURL options:options context:context progress:progressBlock completed:^void(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {
if (fixBlock) {
fixBlock(image,data,error,cacheType,finished,imageURL);
}
}];
}
`
細心的同學可以發現我們增加了一個引數
corp,如果上層業務就是需要按照他傳入的大小來的話,我們做一層裁剪縮放操作。具體操作放在了
fixBlock``中。預設是不進行fix的,因為本身nos伺服器下發的圖片也不一定是業務傳入希望的尺寸。
fixblock 核心的程式碼是用了sd_webimage自帶的裁剪
``` cutImage = [image sd_resizedImageWithSize:requestSize scaleMode:[urlInfo.cropStr isEqualToString:@"x"] ? SDImageScaleModeAspectFit : SDImageScaleModeAspectFill];
```
程式碼到這裡基本fixURL
操作基本完成,但是如果需要相容老的快取(本地已經有的,而且永久快取(特殊case),但是線上已經下架的資源圖片的),在fixblock中,我們在載入失敗的情況下,用老的URL撈了一次本地快取。
[[self sharedManager] loadImageWithURL:url options:options | SDWebImageFromCacheOnly context:mutContext.copy progress:nil completed:completedBlock];
注意:已經fix過的URL不會再fix,是否是永久快取是通過imageCache區分的
SDWebImageFromCacheOnly 表示只從快取了讀取,避免了重複發請求的問題。
imageCache
上面說到要實現複用,需要修改imageCache,這裡不得不提以下SDWebImage找到快取的流程
從圖中可以看出,URL需要轉為cacheKey,然後再從記憶體或者磁碟中撈出快取。那麼我們如何改造呢,因為我們需要通過URL找到本地可以重用的圖片
cacheKey需要保留一定規則,通過cache可以看到原始URL的一些東西。所以我們cachekey是這麼生成的
``` + (NSString )cacheKeyForURL:(NSURL )url {
NSURL *wUrl = url;
NSString *host = wUrl.host;
NSString *absoluteString = wUrl.absoluteString;
if (!host)
{
return absoluteString;
}
NSRange hostRange = [absoluteString rangeOfString:host];
if (hostRange.location + hostRange.length < absoluteString.length)
{
NSString *subString = [absoluteString substringFromIndex:hostRange.location + hostRange.length];
if (subString.length != 0)
{
return subString;
}
}
return absoluteString;
} ``` 簡而言之,就是去掉host,保留剩餘的引數。ps:因為fixURL去過請求引數重複,所以cacheKey也能同一張圖片保證唯一。
那通過URL怎麼找到本地的其他圖片呢,如何關聯上呢?
可以通過path,再查詢關聯的cachekey,然後找到對應的圖片
找到圖片後,選擇出一張可以使用的,對其進行裁剪操作,流程如下:
我們這裡對快取的圖片資訊封裝了一個物件,注意 會用資料庫持久化 ImageCacheKeyAndURLObject
陣列,他的key是請求URL連結中的 path
,注意資料庫有上限大小,同時會在適當的時機清理(如圖片快取過期等)
下面是封裝持久化的物件
``` @interface WebImageCacheImageInfo : NSObject
@property (nonatomic) BOOL isAnimation; @property (nonatomic) CGFloat sizeW; @property (nonatomic) CGFloat sizeH;
- (CGSize)size;
@end
@interface WebImageURLInfo : NSObject
@property (nonatomic) CGSize requestSize; @property (nonatomic) NSString *cropStr; @property (nonatomic) NSInteger quality; @property (nonatomic) NSInteger enlarge;
@end
@interface WebImageCacheKeyAndURLObject : NSObject
@property (nonatomic, readonly) NSString path; @property (nonatomic) NSString cacheKey; @property (nonatomic, nullable) NSURL url; @property (nonatomic, nullable) WebImageCacheImageInfo imageInfo;
- (NSArray
*)relationObjects; - (nullable WebImageCacheKeyAndURLObject *)canReuseObject;
- (WebImageURLInfo *)urlInfo;
- (void)storeImage:(UIImage *)image;
- (void)remove; @end
```
如何儲存圖片資訊呢
``` - (void)storeImage:(UIImage *)image { if (self.path.length == 0) { return; } BOOL isAniamtion = image.sd_isAnimated; CGSize size = image.size; if (image) { _imageInfo = [WebImageCacheImageInfo new]; _imageInfo.sizeH = size.height; _imageInfo.sizeW = size.width; _imageInfo.isAnimation = isAniamtion; }
NSMutableArray<WebImageCacheKeyAndURLObject *> *items = [[self searchfromDBUsePath:self.path] mutableCopy];
if (items.count == 0) {
items = @[].mutableCopy;
}
if ([items containsObject:self]) {
[items removeObject:self];
}
[items addObject:self];
[self saveDBForPath:self.path item:items];
} ```
如何判斷圖片是否可以複用呢?
``` - (WebImageCacheKeyAndURLObject *)canReuseObject {
WebImageURLInfo *info = self.urlInfo;
if (CGSizeEqualToSize(CGSizeZero, info.requestSize)) {
return nil;
}
NSArray<WebImageCacheKeyAndURLObject *> *relationObjects = [self relationObjects];
// 非動圖 尺寸非0
relationObjects = [relationObjects bk_select:^BOOL(WebImageCacheKeyAndURLObject *obj) {
return !obj.imageInfo.isAnimation && obj.imageInfo.size.width > 0 && obj.imageInfo.size.height > 0;
}];
@weakify(self)
relationObjects = [relationObjects bk_select:^BOOL(WebImageCacheKeyAndURLObject *obj) {
@strongify(self)
return ![obj.cacheKey isEqualToString:self.cacheKey];
}];
// 質量大於請求的圖
relationObjects = [relationObjects bk_select:^BOOL(WebImageCacheKeyAndURLObject *obj) {
WebImageURLInfo *objInfo = obj.urlInfo;
NSInteger quality = objInfo.quality == 0 ? 75 : objInfo.quality;
NSInteger requestQuality = info.quality == 0 ? 75 : info.quality;
return quality >= requestQuality;
}];
//縮放能支援的
NSArray<WebImageCacheKeyAndURLObject *> *canUses = nil;
if ([info.cropStr isEqualToString:@"x"] || [info.cropStr isEqualToString:@"z"]) {
canUses = [relationObjects bk_select:^BOOL(WebImageCacheKeyAndURLObject *obj) {
WebImageURLInfo *objInfo = obj.urlInfo;
if ([objInfo.cropStr isEqualToString:@"x"] || [objInfo.cropStr isEqualToString:@"z"]) {
CGSize displaySize = WebImageDisplaySizeForImageSizeContentSizeContentMode(obj.imageInfo.size, info.requestSize, [info.cropStr isEqualToString:@"x"] ? UIViewContentModeScaleAspectFit : UIViewContentModeScaleAspectFill);
CGFloat p = 0;
if (info.requestSize.width > 0) {
p = displaySize.width / obj.imageInfo.size.width;
} else {
p = displaySize.height / obj.imageInfo.size.height;
}
return p <= 1;
} else {
// y 不可以轉z/x
return NO;
}
}];
} else if ([info.cropStr isEqualToString:@"y"]) {
canUses = [relationObjects bk_select:^BOOL(WebImageCacheKeyAndURLObject *obj) {
WebImageURLInfo *objInfo = obj.urlInfo;
if ([objInfo.cropStr isEqualToString:@"x"] || [objInfo.cropStr isEqualToString:@"z"]) {
CGSize displaySize = WebImageDisplaySizeForImageSizeContentSizeContentMode(obj.imageInfo.size, info.requestSize, UIViewContentModeScaleAspectFill);
CGFloat p = 0;
if (info.requestSize.width > 0) {
p = displaySize.width / obj.imageInfo.size.width;
} else {
p = displaySize.height / obj.imageInfo.size.height;
}
return p <= 1;
} else if ([objInfo.cropStr isEqualToString:@"y"]) {
return (obj.imageInfo.size.width >= info.requestSize.width && obj.imageInfo.size.height >= info.requestSize.height);
}
return NO;
}];
}
return canUses.firstObject;
}
```
要過濾動圖,因為動圖本地裁剪比較難處理,而且佔比不高,所以這裡先忽略他,WebImageCacheKeyAndURLObject
記錄了cacheKey
等一些關聯資訊,核心還記錄了實際快取的圖片尺寸。方便查詢。WebImageDisplaySizeForImageSizeContentSizeContentMode
就是傳入圖片大小,容器大小,填充模式計算出縮放後的圖片大小。
關聯關係有了,再什麼時機去查詢呢? 我們繼承SDImageCache,重寫了他
- (nullable NSData *)diskImageDataBySearchingAllPathsForKey:(nullable NSString *)key;
這個方法,在找不到data的情況下更進一步查詢。找到的關聯圖片進行裁剪,使用和上面一樣的修正方法
``` if ([objInfo.cropStr isEqualToString:@"x"] || [objInfo.cropStr isEqualToString:@"z"]) { result = [result fixResizedImageWithSize:requestSize scaleMode:[objInfo.cropStr isEqualToString:@"x"] ? UIViewContentModeScaleAspectFit : UIViewContentModeScaleAspectFill needCorp:NO]; } else if ([objInfo.cropStr isEqualToString:@"y"]) { result = [result fixResizedImageWithSize:requestSize scaleMode:UIViewContentModeScaleAspectFill needCorp:YES]; }
```
這裡補充下fixsize方法
``` - (UIImage *)fixResizedImageWithSize:(CGSize)size scaleMode:(UIViewContentMode)scaleMode needCorp:(BOOL)needCorp {
if (scaleMode != UIViewContentModeScaleAspectFit && scaleMode!= UIViewContentModeScaleAspectFill) {
return self;
}
// 如果是fill模式,實際size會大於容器size 如果需要裁剪為容器大小就不走這一步了
if (scaleMode == UIViewContentModeScaleAspectFill && !needCorp) {
size = WebImageDisplaySizeForImageSizeContentSizeContentMode(self.size, size, scaleMode);
}
UIImage *fixImage = [self sd_resizedImageWithSize:size scaleMode:scaleMode == UIViewContentModeScaleAspectFit ? SDImageScaleModeAspectFit : SDImageScaleModeAspectFill];
return fixImage;
} ```
這樣我們就可以得到修復後的圖片,流程完成。
UIImageView 及 UIButton 等分類
我們包裝一層自己的下載,然後傳入我們的manager即可。
context = @{
SDWebImageContextCustomManager:[WebImageManager sharedManager]
};
額外說一點
CDN命中率和這個資源是否曾經被請求過有關,命中CDN的key又是請求的URL,所以大前端請求都保持一致的規則很重要!這樣每一端都可以蹭到其他端預熱過的圖片資源。
總結
我們核心點就修正了URL
改造了SDWebImageManager
,SDImageCache
,並且建立了CacheKey
關聯關係,並且相容一些老邏輯
這樣本地流程就都算走通了。本文除了常規優化圖片的思路外提供了一種新的思路,本地利用已經下載過的大小圖做文章,從而起到加速及節流的效果,並取得一定的收益,如果讀者也是採用類似拼接url下載圖片的方式的話,這種優化方式可以一試。全部做完取得成果具體數值不便展示,大概為提升下載速度 50%,同時能節省一定的 CDN頻寬,日均節約至少 10% 。
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!
- 不一樣的Android堆疊抓取方案
- 雲音樂 Swift 混編 Module 化實踐
- iOS雲音樂APM效能監控實踐
- 雲音樂iOS端程式碼靜態檢測實踐
- 網易雲音樂全面開源一款雲原生應用部署平臺:Horizon
- dex 優化編年史
- 如何實現 iOS 16 帶來的 Depth Effect 圖片效果
- 雲音樂 iOS 跨端快取庫 - NEMichelinCache
- 雲音樂 Android 記憶體監控探索篇
- Android APP 出海實踐
- Android 除錯實戰與原理詳解
- 社交場景下iOS訊息流互動層實踐
- 你構建的程式碼為什麼這麼大
- 扒一扒 Jetpack Compose 實現原理
- Recoil 狀態管理方案的淺入淺出
- 基於自建 VTree 的全鏈路埋點方案
- 雲音樂 iOS 啟動效能優化「開荒篇」
- 雲音樂播放頁直播推薦實戰
- 雲音樂iOS端網路圖片下載優化實踐
- 雲音樂 iOS 啟動效能優化「開荒篇」