iOS緩存設(shè)計(YYCache思路)
前言:
前段時間業(yè)務(wù)有緩存需求,于是結(jié)合YYCache和業(yè)務(wù)需求扒磁,做了緩存層(內(nèi)存&磁盤)+ 網(wǎng)絡(luò)層的方案嘗試
由于YYCache 采用了內(nèi)存緩存和磁盤緩存組合方式袱箱,性能優(yōu)良遏乔,這里拿它的原理來說下如何設(shè)計一套緩存的思路,并結(jié)合網(wǎng)絡(luò)整理一套完整流程
目錄
- 初步認識緩存
- 如何優(yōu)化緩存(YYCache設(shè)計思想)
- 網(wǎng)絡(luò)和緩存同步流程
一发笔、初步認識緩存
1. 什么是緩存盟萨?
我們做一個緩存前,先了解它是什么了讨,緩存是本地數(shù)據(jù)存儲捻激,存儲方式主要包含兩種:磁盤儲存和內(nèi)存存儲
1.1 磁盤存儲
磁盤緩存制轰,磁盤也就是硬盤緩存,磁盤是程序的存儲空間胞谭,磁盤緩存容量大速度慢垃杖,磁盤是永久存儲東西的,iOS為不同數(shù)據(jù)管理對存儲路徑做了規(guī)范如下:
1丈屹、每一個應(yīng)用程序都會擁有一個應(yīng)用程序沙盒调俘。
2、應(yīng)用程序沙盒就是一個文件系統(tǒng)目錄旺垒。
沙盒根目錄結(jié)構(gòu):Documents彩库、Library、temp先蒋。
磁盤存儲方式主要有文件管理和數(shù)據(jù)庫侧巨,其特性:
1.2 內(nèi)存存儲
內(nèi)存緩存,內(nèi)存緩存是指當前程序運行空間鞭达,內(nèi)存緩存速度快容量小司忱,它是供cpu直接讀取,比如我們打開一個程序畴蹭,他是運行在內(nèi)存中的坦仍,關(guān)閉程序后內(nèi)存又會釋放。
iOS內(nèi)存分為5個區(qū):棧區(qū)叨襟,堆區(qū)繁扎,全局區(qū),常量區(qū)糊闽,代碼區(qū)
棧區(qū)stack:這一塊區(qū)域系統(tǒng)會自己管理梳玫,我們不用干預(yù),主要存一些局部變量右犹,以及函數(shù)跳轉(zhuǎn)時的現(xiàn)場保護提澎。因此大量的局部變量,深遞歸,函數(shù)循環(huán)調(diào)用都可能導(dǎo)致內(nèi)存耗盡而運行崩潰念链。
堆區(qū)heap:與棧區(qū)相對盼忌,這一塊一般由我們自己管理,比如alloc掂墓,free的操作谦纱,存儲一些自己創(chuàng)建的對象。
全局區(qū)(靜態(tài)區(qū)static):全局變量和靜態(tài)變量都存儲在這里君编,已經(jīng)初始化的和沒有初始化的會分開存儲在相鄰的區(qū)域跨嘉,程序結(jié)束后系統(tǒng)會釋放
常量區(qū):存儲常量字符串和const常量
代碼區(qū):存儲代碼
在程序中聲明的容器(數(shù)組 、字典)都可看做內(nèi)存中存儲吃嘿,特性如下:
2. 緩存做什么祠乃?
我們使用場景比如:離線加載窘游,預(yù)加載,本地通訊錄...等跳纳,對非網(wǎng)絡(luò)數(shù)據(jù)忍饰,使用本地數(shù)據(jù)管理的一種,具體使用場景有很多
3. 怎么做緩存寺庄?
簡單緩存可以僅使用磁盤存儲艾蓝,iOS主要提供四種磁盤存儲方式:
-
NSKeyedArchiver
: 采用歸檔的形式來保存數(shù)據(jù), 該數(shù)據(jù)對象需要遵守NSCoding協(xié)議, 并且該對象對應(yīng)的類必須提供encodeWithCoder:和initWithCoder:方法.
//自定義Person實現(xiàn)歸檔解檔
//.h文件
#import <Foundation/Foundation.h>
@interface Person : NSObject<NSCoding>
@property(nonatomic,copy) NSString * name;
@end
//.m文件
#import "Person.h"
@implementation Person
//歸檔要實現(xiàn)的協(xié)議方法
- (void)encodeWithCoder:(NSCoder *)aCoder {
[aCoder encodeObject:_name forKey:@"name"];
}
//解檔要實現(xiàn)的協(xié)議方法
- (instancetype)initWithCoder:(NSCoder *)aDecoder {
if (self = [super init]) {
_name = [aDecoder decodeObjectForKey:@"name"];
}
return self;
}
@end
使用歸檔解檔
// 將數(shù)據(jù)存儲在path路徑下歸檔文件
[NSKeyedArchiver archiveRootObject:p toFile:path];
// 根據(jù)path路徑查找解檔文件
Person *p = [NSKeyedUnarchiver unarchiveObjectWithFile:path];
缺點:歸檔的形式來保存數(shù)據(jù),只能一次性歸檔保存以及一次性解壓。所以只能針對小量數(shù)據(jù),如果想改動數(shù)據(jù)的某一小部分,需要解壓整個數(shù)據(jù)或者歸檔整個數(shù)據(jù)斗塘。
-
NSUserDefaults
: 用來保存應(yīng)用程序設(shè)置和屬性赢织、用戶保存的數(shù)據(jù)。用戶再次打開程序或開機后這些數(shù)據(jù)仍然存在馍盟。
NSUserDefaults可以存儲的數(shù)據(jù)類型包括:NSData于置、NSString、NSNumber贞岭、NSDate八毯、NSArray、 NSDictionary瞄桨。
// 以鍵值方式存儲
[[NSUserDefaults standardUserDefaults] setObject:@"value" forKey:@"key"];
// 以鍵值方式讀取
[[NSUserDefaults standardUserDefaults] objectForKey:@"key"];
-
Write寫入方式
:永久保存在磁盤中话速。具體方法為:
//將NSData類型對象data寫入文件,文件名為FileName
[data writeToFile:FileName atomically:YES];
//從FileName中讀取出數(shù)據(jù)
NSData *data=[NSData dataWithContentsOfFile:FileName options:0 error:NULL];
-
SQLite
:采用SQLite數(shù)據(jù)庫來存儲數(shù)據(jù)。SQLite作為?一中小型數(shù)據(jù)庫,應(yīng)用ios中跟其他三種保存方式相比,相對復(fù)雜一些
//打開數(shù)據(jù)庫
if (sqlite3_open([databaseFilePath UTF8String], &database)==SQLITE_OK) {
NSLog(@"sqlite dadabase is opened.");
} else { return;}//打開不成功就返回
//在打開了數(shù)據(jù)庫的前提下,如果數(shù)據(jù)庫沒有表,那就開始建表了哦!
char *error;
const char *createSql="create table(id integer primary key autoincrement, name text)"; if (sqlite3_exec(database, createSql, NULL, NULL, &error)==SQLITE_OK) {
NSLog(@"create table is ok.");
} else {
sqlite3_free(error);//每次使用完畢清空error字符串,提供給下?一次使用
}
// 建表完成之后, 插入記錄
const char *insertSql="insert into a person (name) values(‘gg’)";
if (sqlite3_exec(database, insertSql, NULL, NULL, &error)==SQLITE_OK) {
NSLog(@"insert operation is ok.");
} else {
sqlite3_free(error);//每次使用完畢清空error字符串,提供給下一次使用
}
上面提到的磁盤存儲特性芯侥,具備空間大泊交、可持久、但是讀取慢柱查,面對大量數(shù)據(jù)頻繁讀取時更加明顯廓俭,以往測試中磁盤讀取比內(nèi)存讀取保守測量低于幾十倍,那我們怎么解決磁盤讀取慢的缺點呢? 又如何利用內(nèi)存的優(yōu)勢呢唉工?
二研乒、 如何優(yōu)化緩存(YYCache設(shè)計思想)
YYCache背景知識:
源碼中由兩個主要類構(gòu)成
- YYMemoryCache (內(nèi)存緩存)
操作YYLinkedMap中數(shù)據(jù), 為實現(xiàn)內(nèi)存優(yōu)化酵紫,采用雙向鏈表數(shù)據(jù)結(jié)構(gòu)實現(xiàn) LRU算法告嘲,YYLinkedMapItem 為每個子節(jié)點 - YYDiskCache (磁盤緩存)
不會直接操作緩存對象(sqlite/file),而是通過 YYKVStorage 來間接的操作緩存對象奖地。
容量管理:
- ageLimit :時間周期限制,比如每天或每星期開始清理
- costLimit: 容量限制赋焕,比如超出10M后開始清理內(nèi)存
- countLimit : 數(shù)量限制参歹, 比如超出1000個數(shù)據(jù)就清理
這里借用YYCache設(shè)計, 來講述緩存優(yōu)化
1. 磁盤+內(nèi)存組合優(yōu)化
利用內(nèi)存和磁盤特性,融合各自優(yōu)點隆判,整合如下:
- APP會優(yōu)先請求內(nèi)存緩沖中的資源
- 如果內(nèi)存緩沖中有犬庇,則直接返回資源文件僧界, 如果沒有的話,則會請求資源文件臭挽,這時資源文件默認資源為本地磁盤存儲捂襟,需要操作文件系統(tǒng)或數(shù)據(jù)庫來獲取。
- 獲取到的資源文件欢峰,先緩存到內(nèi)存緩存葬荷,方便以后不再重復(fù)獲取,節(jié)省時間纽帖。
然后就是從緩存中取到數(shù)據(jù)然后給app使用宠漩。
這樣就充分結(jié)合兩者特性,利用內(nèi)存讀取快特性減少讀取數(shù)據(jù)時間懊直,
YYCache 源碼解析
:
- (id<NSCoding>)objectForKey:(NSString *)key {
// 1.如果內(nèi)存緩存中存在則返回數(shù)據(jù)
id<NSCoding> object = [_memoryCache objectForKey:key];
if (!object) {
// 2.若不存在則查取磁盤緩存數(shù)據(jù)
object = [_diskCache objectForKey:key];
if (object) {
// 3.并將數(shù)據(jù)保存到內(nèi)存中
[_memoryCache setObject:object forKey:key];
}
}
return object;
}
2. 內(nèi)存優(yōu)化-- 提高內(nèi)存命中率
但是我們想在基礎(chǔ)上再做優(yōu)化扒吁,比如想讓經(jīng)常訪問的數(shù)據(jù)保留在內(nèi)存中,提高內(nèi)存的命中率室囊,減少磁盤的讀取雕崩,那怎么做處理呢? -- LRU算法
LRU算法:我們可以將鏈表看成一串數(shù)據(jù)鏈融撞,每個數(shù)據(jù)是這個串上的一個節(jié)點晨逝,經(jīng)常訪問的數(shù)據(jù)移動到頭部,等數(shù)據(jù)超出容量后從鏈表后面的一些節(jié)點銷毀懦铺,這樣經(jīng)常訪問數(shù)據(jù)在頭部位置捉貌,還保留在內(nèi)存中。
鏈表實現(xiàn)結(jié)構(gòu)圖:
YYCache 源碼解析
/**
A node in linked map.
Typically, you should not use this class directly.
*/
@interface _YYLinkedMapNode : NSObject {
@package
__unsafe_unretained _YYLinkedMapNode *_prev; // retained by dic
__unsafe_unretained _YYLinkedMapNode *_next; // retained by dic
id _key;
id _value;
NSUInteger _cost;
NSTimeInterval _time;
}
@end
@implementation _YYLinkedMapNode
@end
/**
A linked map used by YYMemoryCache.
It's not thread-safe and does not validate the parameters.
Typically, you should not use this class directly.
*/
@interface _YYLinkedMap : NSObject {
@package
CFMutableDictionaryRef _dic; // do not set object directly
NSUInteger _totalCost;
NSUInteger _totalCount;
_YYLinkedMapNode *_head; // MRU, do not change it directly
_YYLinkedMapNode *_tail; // LRU, do not change it directly
BOOL _releaseOnMainThread;
BOOL _releaseAsynchronously;
}
/// Insert a node at head and update the total cost.
/// Node and node.key should not be nil.
- (void)insertNodeAtHead:(_YYLinkedMapNode *)node;
/// Bring a inner node to header.
/// Node should already inside the dic.
- (void)bringNodeToHead:(_YYLinkedMapNode *)node;
/// Remove a inner node and update the total cost.
/// Node should already inside the dic.
- (void)removeNode:(_YYLinkedMapNode *)node;
/// Remove tail node if exist.
- (_YYLinkedMapNode *)removeTailNode;
/// Remove all node in background queue.
- (void)removeAll;
@end
_YYLinkedMapNode *_prev
為該節(jié)點的頭指針冬念,指向前一個節(jié)點
_YYLinkedMapNode *_next
為該節(jié)點的尾指針趁窃,指向下一個節(jié)點
頭指針和尾指針將一個個子節(jié)點串連起來,形成雙向鏈表
來看下bringNodeToHead:
的源碼實現(xiàn)急前,它是實現(xiàn)LRU算法主要方法醒陆,移動node子結(jié)點到鏈頭。
(詳細已注釋在代碼中)
- (void)bringNodeToHead:(_YYLinkedMapNode *)node {
if (_head == node) return; // 如果當前節(jié)點是鏈頭裆针,則不需要移動
// 鏈表中存了兩個指向鏈頭(_head)和鏈尾(_tail)的指針刨摩,便于鏈表訪問
if (_tail == node) {
_tail = node->_prev; // 若當前節(jié)點為鏈尾,則更新鏈尾指針
_tail->_next = nil; // 鏈尾的尾節(jié)點這里設(shè)置為nil
} else {
// 比如:A B C 鏈表, 將 B拿走世吨,將A C重新聯(lián)系起來
node->_next->_prev = node->_prev; // 將node的下一個節(jié)點的頭指針指向node的上一個節(jié)點澡刹,
node->_prev->_next = node->_next; // 將node的上一個節(jié)點的尾指針指向node的下一個節(jié)點
}
node->_next = _head; // 將當前node節(jié)點的尾指針指向之前的鏈頭,因為此時node為最新的第一個節(jié)點
node->_prev = nil; // 鏈頭的頭節(jié)點這里設(shè)置為nil
_head->_prev = node; // 之前的_head將為第二個節(jié)點
_head = node; // 當前node成為新的_head
}
其他方法就不挨個舉例了耘婚,具體可翻看源碼罢浇,這些代碼結(jié)構(gòu)清晰,類和函數(shù)遵循單一職責,接口高內(nèi)聚嚷闭,低耦合攒岛,是個不錯的學(xué)習(xí)示例!
3. 磁盤優(yōu)化 - 數(shù)據(jù)分類存儲
YYDiskCache 是一個線程安全的磁盤緩存胞锰,基于 sqlite 和 file 來做的磁盤緩存灾锯,我們的緩存對象可以自由的選擇存儲類型,
下面簡單對比一下:
- sqlite: 對于小數(shù)據(jù)(例如 NSNumber)的存取效率明顯高于 file嗅榕。
- file: 對于較大數(shù)據(jù)(例如高質(zhì)量圖片)的存取效率優(yōu)于 sqlite顺饮。
所以 YYDiskCache 使用兩者配合,靈活的存儲以提高性能誊册。
另外:
YYDiskCache 具有以下功能:
- 它使用 LRU(least-recently-used) 來刪除對象领突。
- 支持按 cost,count 和 age 進行控制案怯。
- 它可以被配置為當沒有可用的磁盤空間時自動驅(qū)逐緩存對象君旦。
- 它可以自動抉擇每個緩存對象的存儲類型(sqlite/file)以便提供更好的性能表現(xiàn)。
YYCache源碼解析
// YYKVStorageItem 是 YYKVStorage 中用來存儲鍵值對和元數(shù)據(jù)的類
// 通常情況下嘲碱,我們不應(yīng)該直接使用這個類
@interface YYKVStorageItem : NSObject
@property (nonatomic, strong) NSString *key; ///< key
@property (nonatomic, strong) NSData *value; ///< value
@property (nullable, nonatomic, strong) NSString *filename; ///< filename (nil if inline)
@property (nonatomic) int size; ///< value's size in bytes
@property (nonatomic) int modTime; ///< modification unix timestamp
@property (nonatomic) int accessTime; ///< last access unix timestamp
@property (nullable, nonatomic, strong) NSData *extendedData; ///< extended data (nil if no extended data)
@end
/**
YYKVStorage 是基于 sqlite 和文件系統(tǒng)的鍵值存儲金砍。
通常情況下,我們不應(yīng)該直接使用這個類麦锯。
@warning
這個類的實例是 *非* 線程安全的恕稠,你需要確保
只有一個線程可以同時訪問該實例。如果你真的
需要在多線程中處理大量的數(shù)據(jù)扶欣,應(yīng)該分割數(shù)據(jù)
到多個 KVStorage 實例(分片)鹅巍。
*/
@interface YYKVStorage : NSObject
#pragma mark - Attribute
@property (nonatomic, readonly) NSString *path; /// storage 路徑
@property (nonatomic, readonly) YYKVStorageType type; /// storage 類型
@property (nonatomic) BOOL errorLogsEnabled; /// 是否開啟錯誤日志
#pragma mark - Initializer
- (nullable instancetype)initWithPath:(NSString *)path type:(YYKVStorageType)type NS_DESIGNATED_INITIALIZER;
#pragma mark - Save Items
- (BOOL)saveItem:(YYKVStorageItem *)item;
...
#pragma mark - Remove Items
- (BOOL)removeItemForKey:(NSString *)key;
...
#pragma mark - Get Items
- (nullable YYKVStorageItem *)getItemForKey:(NSString *)key;
...
#pragma mark - Get Storage Status
- (BOOL)itemExistsForKey:(NSString *)key;
- (int)getItemsCount;
- (int)getItemsSize;
@end
我們只需要看一下 YYKVStorageType 這個枚舉,它決定著 YYKVStorage 的存儲類型料祠。
YYKVStorageType
/**
存儲類型骆捧,指示“YYKVStorageItem.value”存儲在哪里。
@discussion
通常髓绽,將數(shù)據(jù)寫入 sqlite 比外部文件更快敛苇,但是
讀取性能取決于數(shù)據(jù)大小。在測試環(huán)境 iPhone 6s 64G顺呕,
當數(shù)據(jù)較大(超過 20KB)時從外部文件讀取數(shù)據(jù)比 sqlite 更快枫攀。
*/
typedef NS_ENUM(NSUInteger, YYKVStorageType) {
YYKVStorageTypeFile = 0, // value 以文件的形式存儲于文件系統(tǒng)
YYKVStorageTypeSQLite = 1, // value 以二進制形式存儲于 sqlite
YYKVStorageTypeMixed = 2, // value 將根據(jù)你的選擇基于上面兩種形式混合存儲
};
總結(jié):
這里說了YYCache幾個主要設(shè)計優(yōu)化之處,其實細節(jié)上也有很多不錯的處理株茶,比如:
線程安全
如果說 YYCache 這個類是一個純邏輯層的緩存類(指 YYCache 的接口實現(xiàn)全部是調(diào)用其他類完成)来涨,那么 YYMemoryCache 與 YYDiskCache 還是做了一些事情的(并沒有 YYCache 當甩手掌柜那么輕松),其中最顯而易見的就是 YYMemoryCache 與 YYDiskCache 為 YYCache 保證了線程安全忌卤。
YYMemoryCache 使用了 pthread_mutex 線程鎖來確保線程安全扫夜,而 YYDiskCache 則選擇了更適合它的 dispatch_semaphore,上文已經(jīng)給出了作者選擇這些鎖的原因驰徊。性能
YYCache 中對于性能提升的實現(xiàn)細節(jié):
- 異步釋放緩存對象
- 鎖的選擇
- 使用 NSMapTable 單例管理的 YYDiskCache
- YYKVStorage 中的 _dbStmtCache
- 甚至使用 CoreFoundation 來換取微乎其微的性能提升
3. 網(wǎng)絡(luò)和緩存同步流程
結(jié)合網(wǎng)絡(luò)層和緩存層笤闯,設(shè)計了一套接口緩存方式,比較靈活且速度得到提升; 比如首頁界面可能由多個接口提供數(shù)據(jù)棍厂,沒有采用整塊存儲而是將存儲細分到每個接口中颗味,有API接口控制,基本結(jié)構(gòu)如下:
主要分為:
- 應(yīng)用層 :顯示數(shù)據(jù)
- 管理層: 管理網(wǎng)絡(luò)層和緩存層牺弹,為應(yīng)用層提供數(shù)據(jù)支持
- 網(wǎng)絡(luò)層: 請求網(wǎng)絡(luò)數(shù)據(jù)
- 緩存層: 緩存數(shù)據(jù)
層級圖:
- 服務(wù)端每套數(shù)據(jù)對應(yīng)一個version (或時間戳)浦马,若后臺數(shù)據(jù)發(fā)生變更,則version發(fā)生變化张漂,在返回客戶端數(shù)據(jù)時并將version一并返回晶默。
- 當客戶端請求網(wǎng)絡(luò)時,將本地上一次數(shù)據(jù)對應(yīng)version上傳航攒。
- 服務(wù)端獲取客戶端傳來得version后磺陡,與最新的version進行對比,若version不一致漠畜,則返回最新數(shù)據(jù)币他,若未發(fā)生變化,服務(wù)端不需要返回全部數(shù)據(jù)只需返回304(No Modify) 狀態(tài)值
- 客戶端接到服務(wù)端返回數(shù)據(jù)憔狞,若返回全部數(shù)據(jù)非304蝴悉,客戶端則將最新數(shù)據(jù)同步到本地緩存中;客戶端若接到304狀態(tài)值后瘾敢,表示服務(wù)端數(shù)據(jù)和本地數(shù)據(jù)一致拍冠,直接從緩存中獲取顯示
這也是ETag的大致流程;詳細可以查看 https://baike.baidu.com/item/ETag/4419019?fr=aladdin
源碼示例
- (void)getDataWithPage:(NSNumber *)page pageSize:(NSNumber *)pageSize option:(DataSourceOption)option completion:(void (^)(HomePageListCardModel * _Nullable, NSError * _Nullable))completionBlock {
NSString *cacheKey = CacheKey(currentUser.userId, PlatIndexRecommendation);// 全局靜態(tài)常量 (userid + apiName)
// 根據(jù)需求而定是否需要緩存方式簇抵,網(wǎng)絡(luò)方式走304邏輯
switch (option) {
case DataSourceCache:
{
if ([_cache containsObjectForKey:cacheKey]) {
completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
} else {
completionBlock(nil, LJDError(400, @"緩存中不存在"));
}
}
break;
case DataSourceNetwork:
{
[NetWorkServer requestDataWithPage:page pageSize:pageSize completion:^(id _Nullable responseObject, NSError * _Nullable error) {
if (responseObject && !error) {
HomePageListCardModel *model = [HomePageListCardModel yy_modelWithJSON:responseObject];
if (model.errnonumber == 304) { //取緩存數(shù)據(jù)
completionBlock((HomePageListCardModel *)[self->_cache objectForKey:cacheKey], nil);
} else {
completionBlock(model, error);
[self->_cache setObject:model forKey:cacheKey]; //保存到緩存中
}
} else {
completionBlock(nil, error);
}
}];
}
break;
default:
break;
}
}
這樣做好處:
- 對于不頻繁更新數(shù)據(jù)的接口庆杜,節(jié)省了大量JSON數(shù)據(jù)轉(zhuǎn)化時間
- 節(jié)約流量,節(jié)省加載時長
- 用戶界面顯示加快
總結(jié):項目中并不一定完全這樣做正压,有時候過渡設(shè)計也是一種浪費欣福,多了解其他設(shè)計思路后,針對項目找到適合的才是最好的焦履!
參考文獻:
YYCache: https://github.com/ibireme/YYCache
YYCache 設(shè)計思路 :https://blog.ibireme.com/2015/10/26/yycache/