自定义View是一项非常有成就感的实践,如果有Android基础的同学接触起来应该会比较顺手,基本思路都差不多。项目采用原生的布局方式,实现仿微信消息列表效果,主要是学习自定义Cell,展示头像、标题、消息概要、时间和未读数View。下图左边是样例,右边是实现的效果图。
项目采用MVC模式,Model处理数据,View提供自定义的Cell和Cell的位置大小,ViewController负责获取数据、创建View和实现UITableView的代理和数据源方法,结构如图所示。
MsgModel是消息的数据源,包括头像、标题、消息概要、时间和未读数。DataManager负责解析本地的json文件并封装数据,对消息列表数据增删改查操作。
#import "DataManager.h"
#import "MsgModel.h"
#import "MessageItemFrame.h"
@implementation DataManager
- (NSMutableArray *)onParseJson
{
NSString *jsonStr = nil;
NSMutableArray *dataArr = nil;
//获取项目json文件路径
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"msgdatas" ofType:@"json"];
NSFileManager *fileMgr = [NSFileManager defaultManager];
// NSError *error;
if ([fileMgr fileExistsAtPath:filePath]) {
//可以通过流的形式解析
NSInputStream *is = [[NSInputStream alloc] initWithFileAtPath:filePath];
[is open];
id jsonObj = [NSJSONSerialization JSONObjectWithStream:is options:NSJSONReadingAllowFragments error:nil];
[is close];
if ([jsonObj isKindOfClass:[NSArray class]]) {
dataArr = [[NSMutableArray alloc] init];
for (NSDictionary *dict in (NSArray *) jsonObj) {
MsgModel *model = [[MsgModel alloc] init];
model.msgId = [dict objectForKey:@"_id"];
model.pic = [dict objectForKey:@"picture"];
model.name = [dict objectForKey:@"name"];
model.msg = [dict objectForKey:@"message"];
model.time = [dict objectForKey:@"time"];
model.unreadCount = [(NSNumber *) [dict objectForKey:@"unreadCount"] integerValue];
MessageItemFrame *itemFrame = [[MessageItemFrame alloc] init];
itemFrame.msgModel = model;
[dataArr addObject:itemFrame];
}
}
} else {
NSLog(@"文件不存在!");
return nil;
}
return dataArr;
}
@end
考虑到刚刚接触ISO学习的同学对于Json解析还不是很熟悉,所以补充一下Json解析方法,需要根据不同的数据结构进行解析,核心是通过苹果提供的NSJSONSerialization解析,例如数据是NSDictionary格式:
NSString *jsonArr = @"[{\"_id\":\"5d1edda6710720fb68360a89\",\"name\":\"Daisy Pugh\"},{\"_id\":\"5d1edda6710720fb68360a89\",\"name\":\"DaisyPugh\"}]";
NSData *jsonData = [jsonArr dataUsingEncoding:NSUTF8StringEncoding];
id jsonObj = [NSJSONSerialization JSONObjectWithData:jsonData
options:NSJSONReadingAllowFragments error:nil];
if ([jsonObj isKindOfClass:[NSDictionary class]])
{
NSDictionary *dict = (NSDictionary *) jsonObj;
NSString *_id = [dict objectForKey:@"_id"];
NSString *_pic = [dict objectForKey:@"picture"];
NSLog(@"id: %@ picture: %@", _id, _pic);
}
解析JSON文件有很多种方式,下面主要介绍三种:
第一种根据规范的字符串格式:
jsonStr = [NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:nil];
NSData *jsonData = [jsonStr dataUsingEncoding:NSUTF8StringEncoding];
id jsonObj = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:nil];
第二种是通过直接NSData的initWithContentsOfFile解析文件:
NSData *jsonData = [[NSData alloc] initWithContentsOfFile:filePath];
id jsonObj = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingAllowFragments error:nil];
第三种是流的形式解析文件:
NSInputStream *is = [[NSInputStream alloc] initWithFileAtPath:filePath];
[is open];
id jsonObj = [NSJSONSerialization JSONObjectWithStream:is options:NSJSONReadingAllowFragments error:nil];
[is close];
采用IOS原生的View-UIActivityIndicatorView,通过startAnimating和stopAnimating启动和停止动画。
- (void)initLoadingView
{
self.indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
CGFloat loadingViewWidth = 60;
CGFloat loadingViewHeight = 60;
CGFloat loadingViewX = (SCREENW / 2) - (loadingViewWidth / 2);
CGFloat loadingViewY = (SCREENH / 2) - (loadingViewHeight / 2);
self.indicatorView.frame = CGRectMake(loadingViewX, loadingViewY, loadingViewWidth, loadingViewHeight);
self.indicatorView.color = [UIColor lightGrayColor];
self.indicatorView.hidesWhenStopped = YES;
[self.view addSubview:self.indicatorView];
}
通过重写initWithStyle方法创建并初始化显示头像、标题、消息概要、时间和未读数View,提供cellWithTableView创建Cell并调用initWithStyle方法完成子View的创建和初始化。提供setter方法(setMessageItemFrame)设置Cell子View的数据和位置大小。注:由于代码比较长,没有全部展示。
// WCConversationTableViewCell.h
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@class MessageItemFrame;
@interface WCConversationTableViewCell : UITableViewCell
@property (nonatomic, strong) MessageItemFrame *messageItemFrame;
+ (instancetype)cellWithTableView:(UITableView *)tableView;
@end
NS_ASSUME_NONNULL_END
// WCConversationTableViewCell.m
#import "WCConversationTableViewCell.h"
#import "UIImageView+ImageViewLoader.h"
#import "MsgModel.h"
#import "MessageItemFrame.h"
#import "TimeUtils.h"
@interface WCConversationTableViewCell ()
@end
@implementation WCConversationTableViewCell
+ (instancetype)cellWithTableView:(UITableView *)tableView
{
static NSString *cellId = @"cellID";
//到缓存池中找cell,没有再创建并加入缓存池
WCConversationTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellId];
if (cell == nil) {
cell = [[WCConversationTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellId];
}
return cell;
}
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
UIImageView *avatarView = [[UIImageView alloc] init];
avatarView.layer.cornerRadius = 4;
[self.contentView addSubview:avatarView];
self.avatarView = avatarView;
UILabel *titleLabel = [[UILabel alloc] init];
titleLabel.font = [UIFont boldSystemFontOfSize:16];
titleLabel.numberOfLines = 1;
[self.contentView addSubview:titleLabel];
self.titleLabel = titleLabel;
...
}
return self;
}
/**
初始化数据和布局
@param messageItemFrame 位置数据
*/
- (void)setMessageItemFrame:(MessageItemFrame *)messageItemFrame
{
_messageItemFrame = messageItemFrame;
[self setItemViewData];
[self setItemViewFrame];
}
- (void)setItemViewData
{
MsgModel *model = self.messageItemFrame.msgModel;
if (model.pic) {
[self.avatarView setOnlineImage:model.pic placeholderImage:[UIImage imageNamed:@"icon_touxiang_non"]];
}
self.titleLabel.text = model.name;
self.msgLabel.text = model.msg;
self.timeLabel.text = [TimeUtils getSessionTimePretty:model.time];
if (model.unreadCount <= 0) {
self.unReadCountLable.hidden = YES;
} else {
self.unReadCountLable.hidden = NO;
if (model.unreadCount > 99) {
self.unReadCountLable.text = @"99+";
} else {
self.unReadCountLable.text = [NSString stringWithFormat:@"%i", model.unreadCount];
}
}
}
- (void)setItemViewFrame
{
self.avatarView.frame = self.messageItemFrame.iconF;
self.titleLabel.frame = self.messageItemFrame.nameF;
self.msgLabel.frame = self.messageItemFrame.msgF;
self.timeLabel.frame = self.messageItemFrame.timeF;
self.unReadCountLable.frame = self.messageItemFrame.unReadCountF;
}
@end
为了方便管理Cell的布局,所以抽了一个类负责计算Cell子View的位置和大小,思路是先计算头像的frame,然后根据头像作为参考点,以此布局其他的View。
@implementation MessageItemFrame
- (void)setMsgModel:(MsgModel *)msgModel
{
_msgModel = msgModel;
// 间隙
CGFloat padding = 10;
// 设置头像的frame
CGFloat iconViewX = padding;
CGFloat iconViewY = padding;
CGFloat iconViewW = 40;
CGFloat iconViewH = 40;
self.iconF = CGRectMake(iconViewX, iconViewY, iconViewW, iconViewH);
//标题
CGFloat titleLabelX = CGRectGetMaxX(self.iconF) + padding;
//根据文字的长度和字体计算view的宽度和高度
CGSize titleSize = [self sizeWithString:_msgModel.time font:[UIFont systemFontOfSize:14] maxSize:CGSizeMake(250, MAXFLOAT)];
self.nameF = CGRectMake(titleLabelX, padding, 150, titleSize.height);
CGSize timeSize = [self sizeWithString:[TimeUtils getSessionTimePretty:_msgModel.time] font:[UIFont systemFontOfSize:12] maxSize:CGSizeMake(250, MAXFLOAT)];
//时间
CGFloat timeViewY = padding;
CGFloat timeLabelH = timeSize.height;
CGFloat timeLabelW = timeSize.width;
CGFloat timeViewX = SCREENW - timeLabelW - padding;
self.timeF = CGRectMake(timeViewX, timeViewY, timeLabelW, timeLabelH);
//消息摘要
CGFloat textLabelY = titleSize.height + 1.5 * padding;
CGSize textSize = [self sizeWithString:_msgModel.msg font:NJTextFont maxSize:CGSizeMake([[UIScreen mainScreen] bounds].size.width, 30)];
CGFloat textLabelW = textSize.width - 4 * padding;
CGFloat textLabelH = textSize.height;
self.msgF = CGRectMake(titleLabelX, textLabelY, textLabelW, textLabelH);
//设置红点
CGFloat unReadCountW = 16;
CGFloat unReadCountH = 16;
CGFloat unReadCountX = titleLabelX - padding - unReadCountW / 2;
CGFloat unReadCountY = timeViewY - unReadCountH / 2;
self.unReadCountF = CGRectMake(unReadCountX, unReadCountY, unReadCountW, unReadCountH);
//cell行高
self.cellHeight = CGRectGetMaxY(self.iconF) + padding;
}
/**
根据文字测量大小
@param string 数据
@param font 字体
@param maxSize 限定宽高
@return 测量宽高大小
*/
- (CGSize)sizeWithString:(NSString *)string font:(UIFont *)font maxSize:(CGSize)maxSize
{
NSDictionary *dict = @{NSFontAttributeName: font};
// 如果将来计算的文字的范围超出了指定的范围,返回的就是指定的范围
// 如果将来计算的文字的范围小于指定的范围, 返回的就是真实的范围
CGSize size = [string boundingRectWithSize:maxSize options:NSStringDrawingUsesLineFragmentOrigin attributes:dict context:nil].size;
return size;
}
@end
ViewController承担Controller的工作,负责通过DataManager获取数据并创建和初始化UITableView并实现数据源方法numberOfRowsInSection和代理方法heightForRowAtIndexPath。
- (void)viewDidLoad
{
[super viewDidLoad];
[self initLoadingView];
[self.indicatorView startAnimating];
DataManager *dataMgr = [[DataManager alloc] init];
self.data = [dataMgr onParseJson];
_tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
_tableView.dataSource = self;
_tableView.delegate = self;
[self.indicatorView stopAnimating];
[self.view addSubview:_tableView];
}
- (void)initLoadingView
{
self.indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
CGFloat loadingViewWidth = 60;
CGFloat loadingViewHeight = 60;
CGFloat loadingViewX = (SCREENW / 2) - (loadingViewWidth / 2);
CGFloat loadingViewY = (SCREENH / 2) - (loadingViewHeight / 2);
self.indicatorView.frame = CGRectMake(loadingViewX, loadingViewY, loadingViewWidth, loadingViewHeight);
self.indicatorView.color = [UIColor lightGrayColor];
self.indicatorView.hidesWhenStopped = YES;
[self.view addSubview:self.indicatorView];
}
#pragma mark - 数据源方法
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return self.data.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
WCConversationTableViewCell *cell = [WCConversationTableViewCell cellWithTableView:tableView];
cell.messageItemFrame = self.data[indexPath.row];
return cell;
}
#pragma mark - 代理方法
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
MessageItemFrame *itemF = self.data[indexPath.row];
return itemF.cellHeight;
}
- (BOOL)prefersStatusBarHidden
{
return YES;
}
消息列表每条Cell都要展示图片,而且图片是从网络中下载,如果在主线程加载大量数据和网络请求必定会耗时阻塞主线程并可能导致界面卡顿,所以需要实现图片异步加载和二级缓存。二级缓存分别是内存缓存和磁盘缓存,使用NSData dataWithContentsOfURL的方式请求下载图片没有网络缓存。后续有时间可以通过NSUrlSession的方式设定网络缓存加载图片,实现三级缓存。下面说下实现思路:
ImageCacheQueue通过维护一个NSMutableDictionary进行图片内存缓存,设定的缓存大小是50张图片,注意不推荐这样做,一般情况下是限定存储大小,可以通过NSCache,常用的SDWebImage使用NSCache实现缓存,提供了自动删除策略并且操作是线程安全,本文是希望对于新人可以多练习写代码能力,所以不采用NSCache。如下是内存缓存核心代码:
#pragma mark - 内存缓存图片,最多缓存50条,采用FIFO算法
- (void)cacheImageToMemory:(NSDictionary *)info
{
if (memoryCache.count >= 50) {
[memoryCache removeObjectForKey:[memoryCache.allKeys objectAtIndex:0]];
}
[memoryCache setObject:[info objectForKey:@"image"] forKey:[info objectForKey:@"key"]];
}
磁盘缓存是通过UIImageJPEGRepresentation将图片转成NSData,以图片链接的MD5值作为存储路径写入沙盒文件,核心代码如下:
#pragma mark - 磁盘缓存
- (void)cacheImageToDisk:(NSDictionary *)info
{
NSString *key = [info objectForKey:@"key"];
UIImage *image = [info objectForKey:@"image"];
NSString *localPath = [diskCachePath stringByAppendingPathComponent:[key MD5ForLower32Bate]];
NSData *localData = UIImageJPEGRepresentation(image, 1.0f);
if ([localData length] <= 1) {
return;
}
if (![[NSFileManager defaultManager] fileExistsAtPath:localPath]) {
[[NSFileManager defaultManager] createFileAtPath:localPath contents:localData attributes:nil];
}
}
加载图片是先判断内存是否有缓存,没有则去磁盘查找是否有缓存,都没有再通过网络请求下载图片,图片下载成功就加入内存缓存,然后写入磁盘。
- (UIImage *)getImageFromDiskByKey:(NSString *)key
{
NSString *localPath = [diskCachePath stringByAppendingPathComponent:[key MD5ForLower32Bate]];
if (![[NSFileManager defaultManager] fileExistsAtPath:localPath]) {
return nil;
}
UIImage *image = [[UIImage alloc] initWithContentsOfFile:localPath];
if (nil != image) {
NSDictionary *info = [NSDictionary dictionaryWithObjectsAndKeys:image, @"image", key, @"key", nil];
[self performSelector:@selector(cacheImageToMemory:) withObject:info];
return image;
}
return nil;
}
笔者对ImageCacheQueue进行封装,业务调用很简单,仅仅是如下一行代码即可:
[self.avatarView setOnlineImage:model.pic placeholderImage:[UIImage imageNamed:@"icon_touxiang_non"] withRow:@(self.row)];
因为UITableViewCell复用机制,所以只有创建了一屏的Cell,当移出屏幕的Cell会被循环使用,而图片是异步加载的,当第个Cell发起图片异步下载请求或者下载到某个进度了,突然移出屏幕,这会是第二Cell,第一个Cell这会如果图片也下载成功了,那么第二个Cell首先会展示第一个Cell的图片,在性能或者体验效果方面非常不好,因此需要解决这个问题。
解决方法: 首先在创建Cell之后为子控件UIImageView设置tag标识,tag的值简单为当前cell的行数,当然不推荐这样做,应该设定一个唯一值消息列表的ID或者其他,上述是为了简便操作。
+ (instancetype)cellWithTableView:(UITableView *)tableView withRow:(NSInteger)row
{
...
cell.avatarView.tag = row;
return cell;
}
然后是为Cell绑定图片数据的时候异步请求带上当前Cell的行数,
- (void)setItemViewData
{
MsgModel *model = self.messageItemFrame.msgModel;
if (model.pic) {
[self.avatarView setOnlineImage:model.pic placeholderImage:[UIImage imageNamed:@"icon_touxiang_non"] withRow:@(self.row)];
}
}
在异步下载图片结果返回时比较UIImageView的tag和行数一致才加载图片即可。
- (void)imageDownloader:(AsyncImageDownLoader *)downloader onSuccessWithImage:(UIImage *)image withRow:(NSInteger)row
{
WeakSelf;
dispatch_async(dispatch_get_main_queue(), ^{
StrongSelf;
if (self.tag == row) {
self.image = image;
}
});
}
图片下载完成回调的方法需要通过block子线程到主线程的通信,使用block容易出现循环引用的问题,传统且优雅的方法就是通过weakSelf、strongSelf结合使用。
#define WeakSelf __weak typeof(self) weakSelf = self
#define StrongSelf __strong typeof(weakSelf) self = weakSelf
- (void)imageDownloader:(AsyncImageDownLoader *)downloader onSuccessWithImage:(UIImage *)image withRow:(NSInteger)row
{
WeakSelf;
dispatch_async(dispatch_get_main_queue(), ^{
StrongSelf;
NSLog(@"tag main %d,roe is %d", (int) self.tag, (int) row);
//标识cell加载的图片位置是否正确,解决图片混乱问题
if (self.tag == row) {
self.image = image;
}
});
}
总体而言是实现了效果还是可以的,不足的地方还有很多,有很大的优化空间,比如下载图片任务管理情况、解析时间耗时等情况,这篇文章demo适合新手练练手,熟悉UITableView的使用和图片异步加载等,工作繁忙还没有时间将代码放到GitHub,后续有空了会上传并补充链接,如果有疑惑的同学可以先评论留言,笔者会尽快回复。如果OC语言基础还不是很熟悉的同学推荐看这篇文章–看一篇就够入门Objective-C,干货满满。