美文网首页我的iOS开发小屋iOS开发技术iOS精选博文
iOS数据库离线缓存思路和网络层封装

iOS数据库离线缓存思路和网络层封装

作者: Shelin | 来源:发表于2015-11-20 10:44 被阅读14879次

    一直想总结一下关于iOS的离线数据缓存的方面的问题,然后最近也简单的对AFN进行了再次封装,所有想把这两个结合起来写一下。数据展示型的页面做离线缓存可以有更好的用户体验,用户在离线环境下仍然可以获取一些数据,这里的数据缓存首选肯定是SQLite,轻量级,对数据的存储读取相对于其他几种方式有优势,这里对AFN的封装没有涉及太多业务逻辑层面的需求,主要还是对一些方法再次封装方便使用,解除项目对第三方的耦合性,能够简单的快速的更换底层使用的网络请求代码。这篇主要写离线缓存思路,对AFN的封装只做简单的介绍。

    关于XLNetworkApi

    XLNetworkApi的一些功能和说明:

    • 使用XLNetworkRequest做一些GET、POST、PUT、DELETE请求,与业务逻辑对接部分直接以数组或者字典的形式返回。

    • 以及网络下载、上传文件,以block的形式返回实时的下载、上传进度,上传文件参数通过模型XLFileConfig去存取。

    • 通过继承于XLDataService来将一些数据处理,模型转化封装起来,于业务逻辑对接返回的是对应的模型,减少Controllor处理数据处理逻辑的压力。

    • 自定义一些回调的block

    /**
     请求成功block
     */
    typedef void (^requestSuccessBlock)(id responseObj);
    /**
     请求失败block
     */
    typedef void (^requestFailureBlock) (NSError *error);
    /**
     请求响应block
     */
    typedef void (^responseBlock)(id dataObj, NSError *error);
    /**
     监听进度响应block
     */
    typedef void (^progressBlock)(int64_t bytesWritten, int64_t totalBytesWritten, int64_t totalBytesExpectedToWrite);
    
    • XLNetworkRequest.m部分实现
    #import "XLNetworkRequest.h"
    #import "AFNetworking.h"
    @implementation XLNetworkRequest
     + (void)getRequest:(NSString *)url params:(NSDictionary *)params success:(requestSuccessBlock)successHandler failure:(requestFailureBlock)failureHandler {
    
        AFHTTPRequestOperationManager *manager = [self getRequstManager];
        
        [manager GET:url parameters:params success:^(AFHTTPRequestOperation * _Nonnull operation, id  _Nonnull responseObject) {
            successHandler(responseObject);
        } failure:^(AFHTTPRequestOperation * _Nullable operation, NSError * _Nonnull error) {
            XLLog(@"------请求失败-------%@",error);
            failureHandler(error);
        }];
    }
    
    • 下载部分代码
     //下载文件,监听下载进度
     + (void)downloadRequest:(NSString *)url successAndProgress:(progressBlock)progressHandler complete:(responseBlock)completionHandler {
       
        NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
        AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:sessionConfiguration];
        
        NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:url]];
        NSProgress *kProgress = nil;
        
        NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:&kProgress destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
            
            NSURL *documentUrl = [[NSFileManager defaultManager] URLForDirectory :NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
            
            return [documentUrl URLByAppendingPathComponent:[response suggestedFilename]];
            
        } completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nonnull filePath, NSError * _Nonnull error){
            if (error) {
                XLLog(@"------下载失败-------%@",error);
            }
            completionHandler(response, error);
        }];
        
        [manager setDownloadTaskDidWriteDataBlock:^(NSURLSession * _Nonnull session, NSURLSessionDownloadTask * _Nonnull downloadTask, int64_t bytesWritten, int64_t totalBytesWritten, int64_t totalBytesExpectedToWrite) {
            
            progressHandler(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite);
            
        }];
        [downloadTask resume];
    }
    
    • 上传部分代码
     //上传文件,监听上传进度
      + (void)updateRequest:(NSString *)url params:(NSDictionary *)params fileConfig:(XLFileConfig *)fileConfig successAndProgress:(progressBlock)progressHandler complete:(responseBlock)completionHandler {
    
        NSMutableURLRequest *request = [[AFHTTPRequestSerializer serializer] multipartFormRequestWithMethod:@"POST" URLString:url parameters:params constructingBodyWithBlock:^(id<AFMultipartFormData>  _Nonnull formData) {
            
            [formData appendPartWithFileData:fileConfig.fileData name:fileConfig.name fileName:fileConfig.fileName mimeType:fileConfig.mimeType];
            
        } error:nil];
        
        //获取上传进度
        AFHTTPRequestOperation *operation = [[AFHTTPRequestOperation alloc] initWithRequest:request];
        
        [operation setUploadProgressBlock:^(NSUInteger bytesWritten, long long totalBytesWritten, long long totalBytesExpectedToWrite) {
            
            progressHandler(bytesWritten, totalBytesWritten, totalBytesExpectedToWrite);
            
        }];
        
        [operation setCompletionBlockWithSuccess:^(AFHTTPRequestOperation * _Nonnull operation, id  _Nonnull responseObject) {
            completionHandler(responseObject, nil);
        } failure:^(AFHTTPRequestOperation * _Nonnull operation, NSError * _Nonnull error) {
            
            completionHandler(nil, error);
            if (error) {
                XLLog(@"------上传失败-------%@",error);
            }
        }];
        
        [operation start];
    }
    
    
    • XLDataService.m部分实现
      + (void)getWithUrl:(NSString *)url param:(id)param modelClass:(Class)modelClass responseBlock:(responseBlock)responseDataBlock {
            [XLNetworkRequest getRequest:url params:param success:^(id responseObj) {
            //数组、字典转化为模型数组
               
            dataObj = [self modelTransformationWithResponseObj:responseObj modelClass:modelClass];
            responseDataBlock(dataObj, nil);
            
        } failure:^(NSError *error) {
            responseDataBlock(nil, error);
        }];
    }
    
    • (关键)下面这个方法提供给继承XLDataService的子类重写,将转化为模型的代码写在这里,相似业务的网络数据请求都可以用这个子类去请求数据,直接返回对应的模型数组。
    /**
     数组、字典转化为模型
     */
      + (id)modelTransformationWithResponseObj:(id)responseObj modelClass:(Class)modelClass {
           return nil;
    }
    
    关于离线数据缓存

    当用户进入程序的展示页面,有三个情况下可能涉及到数据库存取操作,简单画了个图来理解,思路比较简单,主要是一些存取的细节处理。

    • 进入展示页面

      进入页面.png
    • 下拉刷新最新数据


      下拉刷新.png
    • 上拉加载更多数据


      上拉加载更多.png
    • 需要注意的是,上拉加载更多的时候,每次从数据库返回一定数量的数据,而不是一次性将数据全部加载,否则会有内存问题,直到数据库中没有更多数据时再发生网络请求,再次将新数据存入数据库。这里存储数据的方式是将服务器返回每组数据的字典归档成二进制作为数据库字段直接存储,这样存储在模型属性比较多的情况下更有优势,避免每一个属性作为一个字段,另外增加了一个idStr字段用来判断数据的唯一性,避免重复存储。
      首先定义一个工具类XLDataBase来做数据库相关的操作,这里用的是第三方的FMDB。

    #import "XLDataBase.h"
    #import "FMDatabase.h"
    #import "Item.h"
    #import "MJExtension.h"
    
    @implementation XLDataBase
    
    static FMDatabase *_db;
    
    + (void)initialize {
        
        NSString *path = [NSString stringWithFormat:@"%@/Library/Caches/Data.db",NSHomeDirectory()];
        _db = [FMDatabase databaseWithPath:path];
        [_db open];
        [_db executeUpdate:@"CREATE TABLE IF NOT EXISTS t_item (id integer PRIMARY KEY, itemDict blob NOT NULL, idStr text NOT NULL)"];
    }
    
    //存入数据库
    + (void)saveItemDict:(NSDictionary *)itemDict {
        //此处把字典归档成二进制数据直接存入数据库,避免添加过多的数据库字段
        NSData *dictData = [NSKeyedArchiver archivedDataWithRootObject:itemDict];
        
        [_db executeUpdateWithFormat:@"INSERT INTO t_item (itemDict, idStr) VALUES (%@, %@)",dictData, itemDict[@"id"]];
    }
    
    //返回全部数据
    + (NSArray *)list {
    
        FMResultSet *set = [_db executeQuery:@"SELECT * FROM t_item"];
        NSMutableArray *list = [NSMutableArray array];
        
        while (set.next) {
            // 获得当前所指向的数据
            
            NSData *dictData = [set objectForColumnName:@"itemDict"];
            NSDictionary *dict = [NSKeyedUnarchiver unarchiveObjectWithData:dictData];
            [list addObject:[Item mj_objectWithKeyValues:dict]];
        }
        return list;
    }
    
    //取出某个范围内的数据
    + (NSArray *)listWithRange:(NSRange)range {
        
        NSString *SQL = [NSString stringWithFormat:@"SELECT * FROM t_item LIMIT %lu, %lu",range.location, range.length];
        FMResultSet *set = [_db executeQuery:SQL];
        NSMutableArray *list = [NSMutableArray array];
        
        while (set.next) {
            NSData *dictData = [set objectForColumnName:@"itemDict"];
            NSDictionary *dict = [NSKeyedUnarchiver unarchiveObjectWithData:dictData];
            [list addObject:[Item mj_objectWithKeyValues:dict]];
        }
        return list;
    }
    
    //通过一组数据的唯一标识判断数据是否存在
    + (BOOL)isExistWithId:(NSString *)idStr
    {
        BOOL isExist = NO;
        
        FMResultSet *resultSet= [_db executeQuery:@"SELECT * FROM t_item where idStr = ?",idStr];
        while ([resultSet next]) {
            if([resultSet stringForColumn:@"idStr"]) {
                isExist = YES;
            }else{
                isExist = NO;
            }
        }
        return isExist;
    }
    @end
    
    
    • 一些继承于XLDataService的子类的数据库存储和模型转换的逻辑代码
    #import "GetTableViewData.h"
    #import "XLDataBase.h"
    
    @implementation GetTableViewData
    
    //重写父类方法
    + (id)modelTransformationWithResponseObj:(id)responseObj modelClass:(Class)modelClass {
        NSArray *lists = responseObj[@"data"][@"list"];
        NSMutableArray *array = [NSMutableArray array];
        for (NSDictionary *dict in lists) {
            [modelClass mj_setupReplacedKeyFromPropertyName:^NSDictionary *{
                return @{ @"ID" : @"id" };
            }];
            [array addObject:[modelClass mj_objectWithKeyValues:dict]];
            
            //通过idStr先判断数据是否存储过,如果没有,网络请求新数据存入数据库
            if (![XLDataBase isExistWithId:dict[@"id"]]) {
                //存数据库
                NSLog(@"存入数据库");
                [XLDataBase saveItemDict:dict];
            }
        }
        return array;
    }
    
    
    • 下面是一些控制器的代码实现:
    #import "ViewController.h"
    #import "GetTableViewData.h"
    #import "Item.h"
    #import "XLDataBase.h"
    #import "ItemCell.h"
    #import "MJRefresh.h"
    #define URL_TABLEVIEW @"https://api.108tian.com/mobile/v3/EventList?cityId=1&step=10&theme=0&page=%lu"
    
    @interface ViewController () <UITableViewDataSource, UITableViewDelegate>
    {
        NSMutableArray *_dataArray;
        UITableView *_tableView;
        NSInteger _currentPage;//当前数据对应的page
    }
    @end
    
    @implementation ViewController
    #pragma mark Life cycle
    - (void)viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view, typically from a nib.
        [self createTableView];
        _dataArray = [NSMutableArray array];
    }
    
    - (void)viewWillAppear:(BOOL)animated {
        [super viewWillAppear:animated];
        NSRange range = NSMakeRange(0, 10);
        //如果数据库有数据则读取,不发送网络请求
        if ([[XLDataBase listWithRange:range] count]) {
            [_dataArray addObjectsFromArray:[XLDataBase listWithRange:range]];
            NSLog(@"从数据库加载");
        }else{
            [self getTableViewDataWithPage:0];
        }
    }
    
    #pragma mark UI
    - (void)createTableView {
        _tableView = [[UITableView alloc] initWithFrame:self.view.bounds];
        _tableView.delegate = self;
        _tableView.dataSource = self;
        _tableView.rowHeight = 100.0;
        [self.view addSubview:_tableView];
        
        _tableView.mj_header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{
            [self loadNewData];
        }];
        _tableView.mj_footer = [MJRefreshAutoNormalFooter footerWithRefreshingBlock:^{
            [self loadMoreData];
        }];
    }
    
    #pragma mark GetDataSoure
    - (void)getTableViewDataWithPage:(NSInteger)page {
        NSLog(@"发送网络请求!");
        NSString *url = [NSString stringWithFormat:URL_TABLEVIEW, page];
        [GetTableViewData getWithUrl:url param:nil modelClass:[Item class] responseBlock:^(id dataObj, NSError *error) {
            [_dataArray addObjectsFromArray:dataObj];
            [_tableView reloadData];
            [_tableView.mj_header endRefreshing];
            [_tableView.mj_footer endRefreshing];
        }];
    }
    
    - (void)loadNewData {
        NSLog(@"下拉刷新");
        _currentPage = 0;
        [_dataArray removeAllObjects];
        [self getTableViewDataWithPage:_currentPage];
    }
    
    - (void)loadMoreData {
        NSLog(@"上拉加载");
        _currentPage ++;
        NSRange range = NSMakeRange(_currentPage * 10, 10);
        if ([[XLDataBase listWithRange:range] count]) {
            [_dataArray addObjectsFromArray:[XLDataBase listWithRange:range]];
            [_tableView reloadData];
            [_tableView.mj_footer endRefreshing];
            NSLog(@"数据库加载%lu条更多数据",[[XLDataBase listWithRange:range] count]);
        }else{
            //数据库没更多数据时再网络请求
            [self getTableViewDataWithPage:_currentPage];
        }
    }
    
    #pragma mark UITableViewDataSource
    - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
        return _dataArray.count;
    }
    
    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
        ItemCell *cell = [ItemCell itemCellWithTableView:tableView];
        cell.item = _dataArray[indexPath.row];
        return cell;
    }
    @end
    
    

    最后附上代码的下载地址,重要的部分代码中都有相应的注释和文字打印,运行程序可以很直观的表现。

    https://github.com/ShelinShelin/OffLineCache.git
    有考虑不周的地方,希望大家能提出一些意见,很乐意与大家互相交流。

    相关文章

      网友评论

      • ccSundayChina:提几个问题:
        1、不同页面的网络请求都用一张表么
        2、XLDataBase的设计跟业务强相关,如果我要存储别的模型是否需要修改这个类呢
      • iOS_小胜:写的很清晰,就是简单了点
      • lc_cat:数据库什么时候删除啊 不然越来越大 不删除怎么能行啊 、? 还有就是 下拉的时候吧数据插在前面也不一定对吧 。 我现在刚打开 一刷新就有很多数据插入在原来数据前面 这些数据中间是不是还有可能缺数据 期待大虾指导哈
        Shelin:你可以做个判断,当数据库大于多少条时,执行一个清空的语句
      • 欣东:这里提一点小小意见 :grin: 感觉还是网络层与数据储存层分离比较好,网络层做下缓存请求和响应就够了,跟浏览器缓存一个原理。如果缓存的数据是强业务,那么需要另外构建数据存储层来处理。还有你这里,怎么处理不同用户的缓存数据呢?各个用户的缓存数据都存进去同个数据库会不会不太好。
        First灬DKS:@欣东 你好,不知道有没有好的方法处理这个不同用户的问题呢?
        Shelin:@欣东 对,得根据业务分离开会好很多
      • e4157406efc2:版主问下 一个项目维护两个数据库有什么弊端吗
      • 12eea3acade7:博主你好,能否加QQ一起交流一下技术我的QQ707431492
      • 一抹相思泪成雨:能给个可用的Url吗?
        Shelin:@Hoolink 有时间会尽快替换,抱歉
      • 程序马:确实,对于OA系统,这个方案不适用。
      • 许还真:正好需要写这东东,先看看先。确实缓存到本地时候需要有一个标示来更新本地缓存。可以先作为先读取本地数据默认展示,同时请求网络更新本地存储,然后数据变动更新展示。
      • 南方小金豆:你使用数据来存储网络请求的数据嘛?
        Shelin:@那份牵挂给了谁 抱歉,没太明白你的意思
      • 清水昏昏:不错,小白学习了, :relaxed: :relaxed:
      • syyjay:这个应该有更好的方法 感觉要去实现的地方还是挺多 把判断是否请求网络还是加载本地 可以抽出来
        Shelin:@syyjay 是的,可以抽离的地方还挺多,主要说个思路
      • c2106abf7e14:教教刚入门的小孩还行。
      • abyte:写得很清楚!关于用户很久没有使用之后再上拉刷新数据的问题,看了楼主的解答豁然开朗!不过不知道有没有其他的解决方案呢?还有我发现官方微博的上拉刷新还是需要发送网络请求的,那它上拉刷新后的结果是不是需要存入数据库?如果存入,何时neng用到呢?
      • 咖啡bu加糖:感觉很棒,初学者也能看的懂,就是FMDB还需要多看看
      • 大刀和长剑:不错, 鼓掌👏👏, 下载下来,研究研究, O(∩_∩)O谢谢
      • c4322766f2b8:拉倒吧,没有一点营养
      • 蓦然之间的:不错,思路就是这样,自己写了一半还改了改
      • Will卓然:根据这样的设计模式,需要每一个 View Controller 都写一个类去重写+ (id)modelTransformationWithResponseObj:(id)responseObj modelClass:(Class)modelClass。对吧? 那么如果一个 ViewController中有多个接口呢,就要写多个类去重写该方法对吗?
        Will卓然:@shelin 谢谢你的回复。一个接口对应一个文件,有些复杂吧?
        Shelin:单独继承一个类是为了把相似的接口处理那你在一起,同时也提供直接返回json字典的方法
      • ef00afd7b15d:博主可以做一个CoreData结合的封装吗?
        Shelin:@EricUnicorn 没问题,我也可以学一下
        ef00afd7b15d:@shelin 挺遗憾的,我研究一下,争取仿照你的思路,写一个CoreData的离线缓存方案。到时候还请给点建议
        Shelin:@EricUnicorn Core Data平时用的少,所以了解的不多
      • dbcb81b33e1f:大神棒棒~期待更多好文章
      • 叶孤城___:说没实质性帮助的那位,我期待你写点什么。不要光打嘴炮。
      • 叶孤城___:很不错。不过只有当数据库无值的时候才发起网络请求逻辑好像不太通。如果服务端支持sincelasttimemodify或者etag之类的,应该是1.先从数据库里取值,同时拉取数据。2.当etag不同,拉取新数据,更新数据库,然后更新UI比较好。
        叶孤城___:@斑叔乱翻书 这里我说的有问题,实际是请求的时候在http header里附带etag,然后服务端check request中的etag是否和服务端返回数据的etag相同,如果相同,返回304(记不太清了),body应该为空,意思是这次请求的结果和上次一样,请使用上次请求的结果,并不需要再请求一次接口。
        redSnake:那从第一步来看,还是需要一次网络请求(也就是耗时)判断etag相同不相同
        Shelin:@叶孤城___ 这点我回头再瞅瞅,谢谢彦祖,哈哈
      • Hawthorn_:正看这些啦
      • Azen:好棒!!!!!
      • 我系哆啦:reason: '*** -[__NSArrayM objectAtIndex:]: index 6 beyond bounds for empty array'
      • 我系哆啦:reason: '*** -[__NSArrayM objectAtIndex:]: index 6 beyond bounds for empty array'
      • d897fd10087b:有个问题,下拉刷新,如果有新的数据了。应该将数据库里的数据删掉。否则,你上拉加载更多的时候,数据应该会有问题。
        Shelin:@天芒Jian 这里可能是会有问题,这里用的接口有局限,如果有新数据删除数据库也是可以的,等于重新存数据库
      • LittleMango:代码很优雅,很喜欢看 :heart_eyes:
      • jueyingxx:问下,你这个_db不用close么
      • sclcoder:demo下拉刷新一拉就crash了
        Shelin:@sclcoder 这是iOS9的一个问题,需要调用另一个方法刷新
        sclcoder:@shelin 我也挺奇怪的,在下拉刷新,删除数组数据得时候刷表格了。但并没有reloadData。有可能是xcode的问题吧。
        Shelin:@sclcoder 这边没有遇到这样的情况,不知道你是啥原因crash的
      • 司马捷:先谢谢了,不过上下滑动了几次,就死掉了 - -
      • cd1fcb172f50:用AFURLSessionManager好些吧。
      • Zeayal:赋值
      • Zeayal:AFN二次封装在监听网络状态的时候 声明的_ _block isNetworkUse 的值在block中并没有复制成功 希望你看一下是为什么 是因为多层block的原因吗
        Shelin:@刘紫阳 http://stackoverflow.com/questions/19775646/afnetworking-2-0-reachability/19775935#你可以看看这个stackoverflow弄下来的,监听网络状态的方法setReachabilityStatusChangeBlock只有在网络状态改变的时候才会调用,所以__block BOOL isNetworkUse的只有网络改变再次发送网络请求才会重新赋值
        Zeayal:数据没有被赋值吧_ _block初始值为真 之后在bolck里被改变 但是无论是有没有网络 监听网络状态方法和返回值永远是YES啊 在getRequest方法里 if判断语句好像永远不会执行 是不是我哪里错了 还请指出:sob:
        Shelin:@刘紫阳 模拟器的话在wifi打断点是有赋值的,如果没有赋值成功是不会有网络返回数据的
      • kosser小屋:数据库存二进制数据不太好吧……
        Shelin:暂时还没有发现这样存不太好用的地方,这就给一种存的思路
      • 047ce17af5f0:感谢分享:smile:
      • 萧子然:没啥实质性的东西
      • 5904e8d53a54:不错不错
      • adf8ff2a1ac1:大赞shenlin!
        Shelin:@adf8ff2a1ac1 谢谢😄
      • 天清水蓝:吧错不错
      • e37022936b96:讲解详细,思路清晰,👍赞
      • Zeayal:很厉害
      • 咖杰:楼主 加油 O(∩_∩)O哈哈~
        Shelin:@咖杰 谢谢!
      • 你说丫:顶一下楼主,我刚想做这部分的东西
        Shelin:@m_lv_lin 谢谢!
      • 咖杰:正好我们今天讲到这里 对于学习有很大的帮助
      • 清眸如画:第一次没有数据
        Shelin:@奋斗的月 刚才试了一下,这里没有这样的问题呢
        清眸如画:@shelin 我是说第一次请求网络就做缓存
        Shelin:@奋斗的月 第一次离线没有网络是没有数据的
      • ebay_Happy:楼主你好,看了你的文章非常不错。想跟你交个朋友,交流一下技术问题。可以吗。qq799585817
        Shelin:@beast文强 已加 :grin:
      • Rock86:那对于服务端数据有更新或者删除你怎么处理的哩?
        Rock86:@shelin 还有上面你写的上拉加载有个问题就是,如果我两次打开应用的时间相隔比较长,下拉可以获得最新数据,但是上拉获取的还是很久以前的数据啦
        Rock86:@shelin 比如我在web上把一个文章删了,但是手机端里之前可能已经把这文章缓存到了数据库里。这样你直接打开手机页面还能看到这个已经在后台删除的文章。
        Shelin:@He_Momo 你是指服务端返回数据有更新?不知道我又没有理解错误,如果这样的话,可以将数据库达到一定大小清空,再去存储
      • 方克己:不错。挺好
        方克己:@shelin 必须哒😋😋
        Shelin:@小妖哈哈 谢谢,再接再厉 :grin:

      本文标题:iOS数据库离线缓存思路和网络层封装

      本文链接:https://www.haomeiwen.com/subject/rkslhttx.html