知乎日报——第四周
「OC」知乎日报——第四周(完)
文章目录
- 「OC」知乎日报——第四周(完)
- 本周总结
- 收藏界面
- 使用高度数组优化
- 设置缓存
- 总结
本周总结
本周使用FMDB完成了本地数据的创建,管理相关的点赞收藏信息,优化了tableView,使用高度数组存储动态cell的高度,并且在展开之后更新动态高度数组,使用抽屉视图写了收藏新闻的展示页面。完成了离线缓存功能,在网络请求受阻的情况下,可以使用缓存当中的信息,这样加载过的信息在没有网络的情况下也可以获取信息。
收藏界面
首先收藏界面使用的是抽屉视图,使用的是present模态视图的方法,具体实现有兴趣的读者可以通过「iOS」自定义Modal转场——抽屉视图的实现进行相关的学习。
我们讲一下其中实现本地化存储的FMDB的Manger之中的相关逻辑,首先是和网络请求的思路类似,先设置一个管理类单例来使用FMDB进行管理,这是它的头文件。
#import <Foundation/Foundation.h>
#import "fmdb/FMDB.h"
NS_ASSUME_NONNULL_BEGIN
@class extraInfo;
@interface DBTool : NSObject
-(void)createDB;
-(void)insertInfo:(extraInfo *)info;
- (NSArray<extraInfo *> *)fetchStarInfo;
- (extraInfo *)fetchInfoForNewsID:(NSString *)newsID;
- (void)deleteInfoForNewsID:(NSString *)newsID;
+ (instancetype)sharedManager;
@end
NS_ASSUME_NONNULL_END
可以看到实现的内容也是数据库当中最基础的增删改查,由于不太难,我就直接把实现的方法代码贴出:
#import "DBTool.h"
#import "extraInfo.h"
@interface DBTool ()
@property (strong, nonatomic) FMDatabase *db;
@end
@implementation DBTool
+ (instancetype)sharedManager {
static DBTool *manager;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
manager = [[DBTool alloc] init];
[manager createDB];
[manager createTab];
});
return manager;
}
- (void)createDB {
NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *fileName = [docPath stringByAppendingPathComponent:@"newsInfo.db"];
NSLog(@"%@",docPath);
self.db = [FMDatabase databaseWithPath:fileName];
BOOL isSuccess = [self.db open];
if (!isSuccess) {
NSLog(@"打开数据库失败");
}
}
- (void)createTab {
[self createDB];
NSString *sql = @"CREATE TABLE IF NOT EXISTS newsInfo ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"newsID TEXT UNIQUE, "
"isLiked INTEGER, "
"isFavorited INTEGER, "
"newsTopic TEXT, "
"newsIamge TEXT)";
BOOL isSuccess = [self.db executeUpdate:sql];
if (!isSuccess) {
NSLog(@"数据表创建失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
- (void)insertInfo:(extraInfo *)info {
[self createDB];
NSString *sql = @"INSERT OR REPLACE INTO newsInfo "
"(newsID, isLiked, isFavorited, newsTopic, newsIamge) "
"VALUES (?, ?, ?, ?, ?)";
BOOL isSuccess = [self.db executeUpdate:sql,
info.newsID,
@(info.isLiked),
@(info.isFavorited),
info.newsTopic,
info.newsIamge];
if (!isSuccess) {
NSLog(@"数据插入或更新失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
- (extraInfo *)fetchInfoForNewsID:(NSString *)newsID {
[self createDB];
NSString *sql = @"SELECT isLiked, isFavorited, newsTopic, newsIamge FROM newsInfo WHERE newsID = ?";
FMResultSet *result = [self.db executeQuery:sql, newsID];
extraInfo *info = nil;
if ([result next]) {
info = [[extraInfo alloc] init];
info.newsID = newsID;
info.isLiked = [result boolForColumn:@"isLiked"];
info.isFavorited = [result boolForColumn:@"isFavorited"];
info.newsTopic = [result stringForColumn:@"newsTopic"];
info.newsIamge = [result stringForColumn:@"newsIamge"];
} else {
NSLog(@"未找到新闻 ID 为 %@ 的记录", newsID);
}
[self.db close];
return info;
}
- (NSArray<extraInfo *> *)fetchStarInfo {
[self createDB];
NSString *sql = @"SELECT id, newsID, isLiked, isFavorited, newsTopic, newsIamge FROM newsInfo WHERE isFavorited = 1 ORDER BY id DESC";
FMResultSet *result = [self.db executeQuery:sql];
NSMutableArray<extraInfo *> *infoArray = [NSMutableArray array];
while ([result next]) {
extraInfo *info = [[extraInfo alloc] init];
info.newsID = [result stringForColumn:@"newsID"];
info.isLiked = [result boolForColumn:@"isLiked"];
info.isFavorited = [result boolForColumn:@"isFavorited"];
info.newsTopic = [result stringForColumn:@"newsTopic"];
info.newsIamge = [result stringForColumn:@"newsIamge"];
[infoArray addObject:info];
}
[self.db close];
return infoArray;
}
- (void)deleteInfoForNewsID:(NSString *)newsID {
[self createDB];
NSString *sql = @"DELETE FROM newsInfo WHERE newsID = ?";
BOOL isSuccess = [self.db executeUpdate:sql, newsID];
if (isSuccess) {
NSLog(@"删除新闻 ID %@ 的记录成功", newsID);
} else {
NSLog(@"删除记录失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
@end#import "DBTool.h"
#import "extraInfo.h"
@interface DBTool ()
@property (strong, nonatomic) FMDatabase *db;
@end
@implementation DBTool
+ (instancetype)sharedManager {
static DBTool *manager;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
manager = [[DBTool alloc] init];
[manager createDB];
[manager createTab];
});
return manager;
}
- (void)createDB {
NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *fileName = [docPath stringByAppendingPathComponent:@"newsInfo.db"];
NSLog(@"%@",docPath);
self.db = [FMDatabase databaseWithPath:fileName];
BOOL isSuccess = [self.db open];
if (!isSuccess) {
NSLog(@"打开数据库失败");
}
}
- (void)createTab {
[self createDB];
NSString *sql = @"CREATE TABLE IF NOT EXISTS newsInfo ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"newsID TEXT UNIQUE, "
"isLiked INTEGER, "
"isFavorited INTEGER, "
"newsTopic TEXT, "
"newsIamge TEXT)";
BOOL isSuccess = [self.db executeUpdate:sql];
if (!isSuccess) {
NSLog(@"数据表创建失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
- (void)insertInfo:(extraInfo *)info {
[self createDB];
NSString *sql = @"INSERT OR REPLACE INTO newsInfo "
"(newsID, isLiked, isFavorited, newsTopic, newsIamge) "
"VALUES (?, ?, ?, ?, ?)";
BOOL isSuccess = [self.db executeUpdate:sql,
info.newsID,
@(info.isLiked),
@(info.isFavorited),
info.newsTopic,
info.newsIamge];
if (!isSuccess) {
NSLog(@"数据插入或更新失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
- (extraInfo *)fetchInfoForNewsID:(NSString *)newsID {
[self createDB];
NSString *sql = @"SELECT isLiked, isFavorited, newsTopic, newsIamge FROM newsInfo WHERE newsID = ?";
FMResultSet *result = [self.db executeQuery:sql, newsID];
extraInfo *info = nil;
if ([result next]) {
info = [[extraInfo alloc] init];
info.newsID = newsID;
info.isLiked = [result boolForColumn:@"isLiked"];
info.isFavorited = [result boolForColumn:@"isFavorited"];
info.newsTopic = [result stringForColumn:@"newsTopic"];
info.newsIamge = [result stringForColumn:@"newsIamge"];
} else {
NSLog(@"未找到新闻 ID 为 %@ 的记录", newsID);
}
[self.db close];
return info;
}
- (NSArray<extraInfo *> *)fetchStarInfo {
[self createDB];
NSString *sql = @"SELECT id, newsID, isLiked, isFavorited, newsTopic, newsIamge FROM newsInfo WHERE isFavorited = 1 ORDER BY id DESC";
FMResultSet *result = [self.db executeQuery:sql];
NSMutableArray<extraInfo *> *infoArray = [NSMutableArray array];
while ([result next]) {
extraInfo *info = [[extraInfo alloc] init];
info.newsID = [result stringForColumn:@"newsID"];
info.isLiked = [result boolForColumn:@"isLiked"];
info.isFavorited = [result boolForColumn:@"isFavorited"];
info.newsTopic = [result stringForColumn:@"newsTopic"];
info.newsIamge = [result stringForColumn:@"newsIamge"];
[infoArray addObject:info];
}
[self.db close];
return infoArray;
}
- (void)deleteInfoForNewsID:(NSString *)newsID {
[self createDB];
NSString *sql = @"DELETE FROM newsInfo WHERE newsID = ?";
BOOL isSuccess = [self.db executeUpdate:sql, newsID];
if (isSuccess) {
NSLog(@"删除新闻 ID %@ 的记录成功", newsID);
} else {
NSLog(@"删除记录失败: %@", [self.db lastErrorMessage]);
}
[self.db close];
}
@end
我的存储结构就是,存储对应的序列ID,新闻ID,点赞收藏情况,新闻标题和新闻图片,由于点赞的数量其实需要实时申请,因此不做存储,在详情页的滚动视图到对应位置才进行申请。在详情页之中更新对应的BottomView的代码需要做出更改,内容大致如下:
if (index == self.currentPage) {
extraInfo *cachedInfo = [self getCachedInfoForStory:story];
// 先设置界面上的 UI 状态
[self updateBottomViewWithInfo:cachedInfo];
// 如果缓存中有 extraInfo,且不需要网络请求,则不发送网络请求
if (![self isInfoStale:cachedInfo]) {
return;
}
// 发送网络请求,获取最新的 extraInfo
[[NetworkManager sharedManager] fetchNewsExtraInfo:story.id completion:^(extraInfo *info, NSError *error) {
if (!error && info) {
[self updateInfo:info forStory:story];
[self updateBottomViewWithInfo:info];
} else {
NSLog(@"Error fetching extra info: %@", error);
}
}];
}
- (extraInfo *)getCachedInfoForStory:(Story *)story {
// 1️⃣ 优先从内存缓存中获取
extraInfo *cachedInfo = self.extraInfoCache[story.id];
if (!cachedInfo) {
// 2️⃣ 如果内存缓存中没有,则从数据库中获取
cachedInfo = [[DBTool sharedManager] fetchInfoForNewsID:story.id];
}
if (!cachedInfo) {
// 3️⃣ 如果数据库中也没有,则创建默认的 extraInfo
cachedInfo = [[extraInfo alloc] init];
cachedInfo.newsID = story.id;
cachedInfo.newsTopic = story.title;
cachedInfo.newsIamge = [story.images firstObject];
cachedInfo.isLiked = NO;
cachedInfo.isFavorited = NO;
// ⚡️ 将新创建的缓存对象加入内存缓存,避免重复创建
self.extraInfoCache[story.id] = cachedInfo;
}
return cachedInfo;
}
- (void)updateBottomViewWithInfo:(extraInfo *)info {
if (!info) return; // 防止空数据
// 只更新 UI 一次,避免多次 setSelected
[self.bottomView setInfo:info];
[self.bottomView.like setSelected:info.isLiked];
[self.bottomView.star setSelected:info.isFavorited];
}
这里拿获取详情页的状态的相关数据来举例,首先是是查找字典缓存之中的内容,如果找不到,其次再查找本地数据库之中的内容,如果都找不到就给点赞收藏状态给一个初始值NO。最后再在进行网络请求点赞评论数量时再进行赋值。
通过收藏界面进入的详情页和无限右滑的详情页的区别就是,收藏页的滚动页数是固定的,也没有太大的区别。
使用高度数组优化
我们在编写评论区的时候,由于我们的高度时动态变化的,由于cell的复用机制我们会不断的计算cell之中的高度,所以我们可以通过方法来计算cell的高度,然后将高度存储在C层的字典当中,当滑动到对应cell时可以根据index访问到cell对应的高度。
在这里我使用了一个类方法,去计算cell的高度
+ (CGFloat)heightForComment:(ShortComment *)comment {
CGFloat height = 80;
UIFont *usernameFont = [UIFont boldSystemFontOfSize:18];
UIFont *commentFont = [UIFont systemFontOfSize:16];
CGSize maxTextWidth = CGSizeMake(310, CGFLOAT_MAX);
CGRect usernameRect = [comment.author boundingRectWithSize:maxTextWidth
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName: usernameFont}
context:nil];
height += usernameRect.size.height;
CGRect commentTextRect = [comment.content boundingRectWithSize:maxTextWidth
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName: commentFont}
context:nil];
height += commentTextRect.size.height;
if (comment.replyTo) {
UIFont *replyFont = [UIFont systemFontOfSize:14];
NSString *replyText = [NSString stringWithFormat:@"// %@:%@", comment.replyTo.author, comment.replyTo.content];
if (comment.isExpanded) {
CGRect replyTextRect = [replyText boundingRectWithSize:maxTextWidth
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName: replyFont}
context:nil];
height += replyTextRect.size.height;
} else {
CGFloat lineHeight = replyFont.lineHeight;
CGFloat maxHeight = lineHeight * 3;
CGRect replyTextRect = [replyText boundingRectWithSize:maxTextWidth
options:NSStringDrawingUsesLineFragmentOrigin
attributes:@{NSFontAttributeName: replyFont}
context:nil];
height += MIN(replyTextRect.size.height, maxHeight);
}
}
return height;
}
我将固定的高度累加起来,然后再去动态计算两个textView的高度,计算完后将对应高度存在C层当中,代码逻辑如下
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
NSNumber *commentID = nil;
// 确定评论的 ID
if (indexPath.section == 0 && self.longComments.count > 0) {
commentID = @(self.longComments[indexPath.row].commentId);
} else if ((indexPath.section == 1 || (indexPath.section == 0 && self.longComments.count == 0)) && self.shortComments.count > 0) {
commentID = @(self.shortComments[indexPath.row].commentId);
}
NSNumber *cachedHeight = self.heightCache[commentID];
if (cachedHeight) {
return cachedHeight.floatValue;
}
CGFloat height = 0.0;
if (indexPath.section == 0 && self.longComments.count > 0) {
LongComment* comment = self.longComments[indexPath.row];
height = [LongCommentTableViewCell heightForComment:comment];
} else if ((indexPath.section == 1 || (indexPath.section == 0 && self.longComments.count == 0)) && self.shortComments.count > 0) {
ShortComment *comment = self.shortComments[indexPath.row];
height = [CommentTableViewCell heightForComment:comment];
}
self.heightCache[commentID] = @(height); // 缓存高度
return height;
}
还有一个问题就是,我们要保存我们展开的状态,所以如果我们点击展开,其实cell的高度会随着变化,但是在我们C层当中的数组存储的其实还是未展开时的高度,在这里我使用一个通知传值,当我们点击展开的时候,我们的cell发送通知给C层,根据打包的数据找到对应的cell,将高度字典之中的高度进行一个更新操作,具体操作如下:
//更改数据源之中的isExpanded属性,以及移除高度数组进行重新计算
-(void)reloadCell1:(NSNotification *)notification {
CommentTableViewCell* cell = notification.userInfo[@"cell"];
NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
NSNumber *commentID = notification.userInfo[@"commentID"];
ShortComment *commment = self.shortComments[indexPath.row];
commment.isExpanded = !commment.isExpanded;
commment.isLike = [notification.userInfo[@"isLike"] boolValue];
[self.tableView beginUpdates];
if (commentID) {
[self.heightCache removeObjectForKey:commentID];
}
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
[self.tableView endUpdates];
}
-(void)reloadCell2:(NSNotification *)notification {
LongCommentTableViewCell* cell = notification.userInfo[@"cell"];
NSNumber *commentID = notification.userInfo[@"commentID"];
NSIndexPath *indexPath = [self.tableView indexPathForCell:cell];
LongComment *commment = self.longComments[indexPath.row];
commment.isLike = [notification.userInfo[@"isLike"] boolValue];
commment.isExpanded = !commment.isExpanded;
[self.tableView beginUpdates];
if (commentID) {
[self.heightCache removeObjectForKey:commentID];
}
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic];
[self.tableView endUpdates];
}
设置缓存
由于关于UrlCache
之中的内容其实了解的不是很深,但所幸其实使用起来也不太难,只要对之前的AFN的manger之中的方法进行一点改修就可以了,接下来就介绍一下我实现的缓存机制
首先是在AppDelegate之中,我们给这个程序划分一个全局的内存,并给上对应的关键字,作为缓存
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
NSUInteger memoryCapacity = 50 * 1024 * 1024; // 50 MB 内存缓存
NSUInteger diskCapacity = 200 * 1024 * 1024; // 200 MB 磁盘缓存
NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:memoryCapacity diskCapacity:diskCapacity diskPath:@"networkCache"];
[NSURLCache setSharedURLCache:urlCache];
return YES;
}
紧接着在对应的网络请求方法之中,修改一下
- (void)fetchLatestNewsWithCompletion:(void (^)(News *response, NSError *error))completion {
NSString *urlString = @"https://news-at.zhihu.com/api/4/stories/latest";
NSURL *url = [NSURL URLWithString:urlString];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[self GET:urlString parameters:nil headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSDictionary *userInfo = @{@"cachedDate": [NSDate date]};
NSData *data = [NSJSONSerialization dataWithJSONObject:responseObject options:0 error:nil];
NSCachedURLResponse *newCache = [[NSCachedURLResponse alloc] initWithResponse:task.response data:data userInfo:userInfo storagePolicy:NSURLCacheStorageAllowed];
[[NSURLCache sharedURLCache] storeCachedResponse:newCache forRequest:request];
//当我在网络请求获取到信息的时候,就将对应信息存在缓存当中
News *newsResponse = [News yy_modelWithJSON:responseObject];
if (completion) {
completion(newsResponse, nil);
}
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
//在网络申请失败的时候,根据网络请求找到对应的缓存,将内容
NSCachedURLResponse *cachedResponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request];
if (cachedResponse) {
NSDictionary *userInfo = cachedResponse.userInfo;
NSDate *cachedDate = userInfo[@"cachedDate"];
NSTimeInterval cacheAge = [[NSDate date] timeIntervalSinceDate:cachedDate];
News *newsResponse = [News yy_modelWithJSON:cachedResponse.data];
if (completion) {
![请添加图片描述](https://i-blog.csdnimg.cn/direct/a2ffe5ed3fee425ca38b93e95a5b6904.gif)
completion(newsResponse, nil);
}
return;
}
if (completion) {
completion(nil, error);
}
}];
}
这样就算我们将网络关闭,程序还是会对我们的之前访问过的内容有一个缓存。
总结
到此为止,知乎日报的内容已经大致完成,剩下一些细节还需要慢慢的完善,通过知乎日报这个项目,确实逐步了解了如何用MVC的架构去写一个稍微完整的项目,学习了许多新的知识以及第三方库。当然过程之中仍然暴露了许多之前从未想过到的问题,还是认识到了自身的不足,还是希望自己能够在学习路上越来越精进自己。