前言
刚接手电子书项目时,和安卓开发者pt Cai老师【aipiti Cai,一个我很敬佩很资深的开发工程师,设计领域:c++、Java、安卓、QT等】共同商议了一下,因为项目要做要同步,移动端【手机端】和PC【电脑端】的同步问题,让我们无法决定该用那种方式去呈现电子书,因为PC要展示的电子书有网络图片,有HTML标签,主要功能是能做标记(涂色、划线、书签等),而且后台数据源返回的只有这一种格式:HTML;所以我们第一时间想到了可以用加载网页的Webview来做;pt Cai老师做了一些基于JS的分页及手势操作,然后对图片进行了适配,但是当我在测试Webview时,效果并不尽人意:
- Webview渲染比较慢,加载需要一定的等待时间,体验不是很好;
- Webview内存泄漏比较严重;
- Webview的与本地的交互,交互是有一定的延时,而且对于不断地传递参数不好控制操作;
引入Coretext
通过上面的测试,我决定放弃了Webview,用Coretext来尝试做这些排版和操作;我在网上查了很多资料,从对Coretext的基本开始了解,然后查看了猿题库开发者的博客,在其中学到了不少东西,然后就开始试着慢慢的用Coretext来尝试;
demo
1.主框架
做电子书阅读,首先要有一个翻滚阅读页的一个框架,我并没有选择用苹果自带的 UIPageViewController 因为控制效果不是很好,我再Git上找了一个不错的 DZMCoverAnimation,因为是做demo测试,就先选择一个翻滚阅读页做效果,这个覆盖翻页的效果如下:
2.解析数据源
首先看一下数据源demo,我要求json数据最外层必须是P标签,P标签不能嵌套P标签,但可以包含Img和Br标签,Img标签内必须含有宽高属性,以便做排版时适配,最终的数据源为:
然后我在项目中用CocoaPods引入解析HTML文件的 hpple 三方库,在解析工具类CoreTextSource中添加解析数据模型和方法,假如上面的这个数据源是一章的内容,我把这一章内容最外层的每个P标签当做一个段落,遍历每个段落,然后在遍历每个段落里面的内容和其他标签;
CoreTextSource.h
#import <Foundation/Foundation.h>
#import <hpple/TFHpple.h> #import <UIKit/UIKit.h>
typedef NS_ENUM(NSInteger,CoreTextSourceType){
///文本
CoreTextSourceTypeTxt = ,
///图片
CoreTextSourceTypeImage
}; /**
文本
*/
@interface CoreTextTxtSource : NSObject
@property (nonatomic,strong) NSString *content;
@end /**
图片
*/
@interface CoreTextImgSource : NSObject
@property (nonatomic,strong) NSString *name;
@property (nonatomic,assign) CGFloat width;
@property (nonatomic,assign) CGFloat height;
@property (nonatomic,strong) NSString *url;
// 此坐标是 CoreText 的坐标系,而不是UIKit的坐标系
@property (nonatomic,assign) NSInteger position;
@property (nonatomic,assign) CGRect imagePosition;
@end /**
段落内容
*/
@interface CoreTextParagraphSource : NSObject
@property (nonatomic,assign) CoreTextSourceType type;
@property (nonatomic,strong) CoreTextImgSource *imgData;
@property (nonatomic,strong) CoreTextTxtSource *txtData;
@end
///电子书数据源
@interface CoreTextSource : NSObject
///解析HTML格式
+ (NSArray *)arrayReaolveChapterHtmlDataWithFilePath:(NSString *)filePath;
@end
CoreTextSource.m
#import "CoreTextSource.h" @implementation CoreTextImgSource @end
@implementation CoreTextParagraphSource @end
@implementation CoreTextTxtSource @end @implementation CoreTextSource + (NSArray *)arrayReaolveChapterHtmlDataWithFilePath:(NSString *)filePath{
NSData * data = [NSData dataWithContentsOfFile:filePath]; TFHpple * dataSource = [[TFHpple alloc] initWithHTMLData:data];
NSArray * elements = [dataSource searchWithXPathQuery:@"//p"]; NSMutableArray *arrayData = [NSMutableArray array]; for (TFHppleElement *element in elements) {
NSArray *arrrayChild = [element children];
for (TFHppleElement *elementChild in arrrayChild) {
CoreTextParagraphSource *paragraphSource = [[CoreTextParagraphSource alloc]init];
NSString *type = [elementChild tagName];
if ([type isEqualToString:@"text"]) {
CoreTextTxtSource *text = [[CoreTextTxtSource alloc]init];
text.content = elementChild.content;
paragraphSource.txtData = text;
paragraphSource.type = CoreTextSourceTypeTxt;
}
else if ([type isEqualToString:@"img"]){
CoreTextImgSource *image = [[CoreTextImgSource alloc]init];
NSDictionary *dicAttributes = [elementChild attributes];
image.name = [dicAttributes[@"src"] lastPathComponent];
image.url = dicAttributes[@"src"];
image.width = [dicAttributes[@"width"] floatValue];
image.height = [dicAttributes[@"height"] floatValue];
paragraphSource.imgData = image;
paragraphSource.type = CoreTextSourceTypeImage; if (image.width >= (Scr_Width - )) {
CGFloat ratioHW = image.height/image.width;
image.width = Scr_Width - ;
image.height = image.width * ratioHW;
}
}
else if ([type isEqualToString:@"br"]){
CoreTextTxtSource *text = [[CoreTextTxtSource alloc]init];
text.content = @"\n";
paragraphSource.txtData = text;
paragraphSource.type = CoreTextSourceTypeTxt;
} [arrayData addObject:paragraphSource];
} ///每个个<P>后加换行
CoreTextParagraphSource *paragraphNewline = [[CoreTextParagraphSource alloc]init];
CoreTextTxtSource *textNewline = [[CoreTextTxtSource alloc]init];
textNewline.content = @"\n";
paragraphNewline.txtData = textNewline;
paragraphNewline.type = CoreTextSourceTypeTxt;
[arrayData addObject:paragraphNewline];
} return arrayData;
}
@end
3.图片处理和分页
添加好CoreTextSource类之后,就可以通过 arrayReaolveChapterHtmlDataWithFilePath 方法获取这一章的所有段落内容;但是还有一个问题,既然用Coretext来渲染,那图片要在渲染之前下载好,从本地获取下载好的图片进行渲染,具体什么时候下载,视项目而定;我在CoreTextDataTools类中添加了图片下载方法,该类主要用于分页;在分页之前,添加每个阅读页的model -> CoreTextDataModel,具体图片的渲染,先详看CoreTextDataTools分页类中 wkj_coreTextPaging 方法和其中引用到的方法;
CoreTextDataModel.h
#import <Foundation/Foundation.h> ///标记显示模型
@interface CoreTextMarkModel : NSObject
@property (nonatomic,assign) BookMarkType type;
@property (nonatomic,assign) NSRange range;
@property (nonatomic,strong) NSString *content;
@property (nonatomic,strong) UIColor *color;
@end @interface CoreTextDataModel : NSObject
///
@property (nonatomic,assign) CTFrameRef ctFrame;
@property (nonatomic,strong) NSAttributedString *content;
@property (nonatomic,assign) NSRange range;
///图片数据模型数组 CoreTextImgSource
@property (nonatomic,strong) NSArray *arrayImage;
///标记数组
@property (nonatomic,copy) NSArray *arrayMark;
@end
CoreTextDataModel.m
#import "CoreTextDataModel.h"
@implementation CoreTextMarkModel @end @implementation CoreTextDataModel
- (void)setCtFrame:(CTFrameRef)ctFrame{
if (_ctFrame != ctFrame) {
if (_ctFrame != nil) {
CFRelease(_ctFrame);
}
CFRetain(ctFrame);
_ctFrame = ctFrame;
}
}
@end
CoreTextDataTools.h
///图片下载
+ (void)wkj_downloadBookImage:(NSArray *)arrayParagraph;
///分页
+ (NSArray *)wkj_coreTextPaging:(NSAttributedString *)str
textArea:(CGRect)textFrame
arrayParagraphSource:(NSArray *)arrayParagraph;
///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
+ (NSAttributedString *)wkj_loadChapterParagraphArray:(NSArray *)arrayArray;
CoreTextDataTools.m
#import "CoreTextDataTools.h"
#import <SDWebImage/UIImage+MultiFormat.h> @implementation CoreTextDataTools
+ (void)wkj_downloadBookImage:(NSArray *)arrayParagraph{
dispatch_group_t group = dispatch_group_create();
// 有多张图片URL的数组
for (CoreTextParagraphSource *paragraph in arrayParagraph) {
if (paragraph.type == CoreTextSourceTypeTxt) {
continue;
} dispatch_group_enter(group);
// 需要加载图片的控件(UIImageView, UIButton等)
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:paragraph.imgData.url]];
UIImage *image = [UIImage sd_imageWithData:data];
// 本地沙盒目录
NSString *path = wkj_documentPath;
///创建文件夹
NSString *folderName = [path stringByAppendingPathComponent:@"wkjimage"]; if (![[NSFileManager defaultManager]fileExistsAtPath:folderName]) { [[NSFileManager defaultManager] createDirectoryAtPath:folderName withIntermediateDirectories:YES attributes:nil error:nil]; }else{
NSLog(@"有这个文件了");
} // 得到本地沙盒中名为"MyImage"的路径,"MyImage"是保存的图片名
// NSString *imageFilePath = [path stringByAppendingPathComponent:@"MyImage"]; // 将取得的图片写入本地的沙盒中,其中0.5表示压缩比例,1表示不压缩,数值越小压缩比例越大 folderName = [folderName stringByAppendingPathComponent:[paragraph.imgData.url lastPathComponent]]; BOOL success = [UIImageJPEGRepresentation(image, 0.1) writeToFile:folderName atomically:YES];
if (success){
NSLog(@"写入本地成功");
} dispatch_group_leave(group); }
// 下载图片完成后, 回到主线
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// 刷新UI });
}
/**
CoreText 分页
str: NSAttributedString属性字符串
textFrame: 绘制区域
*/
+ (NSArray *)wkj_coreTextPaging:(NSAttributedString *)str
textArea:(CGRect)textFrame
arrayParagraphSource:(NSArray *)arrayParagraph{
NSMutableArray *arrayCoretext = [NSMutableArray array]; CFAttributedStringRef cfStrRef = (__bridge CFAttributedStringRef)str;
CTFramesetterRef framesetterRef = CTFramesetterCreateWithAttributedString(cfStrRef);
CGPathRef path = CGPathCreateWithRect(textFrame, NULL); int textPos = ;
NSUInteger strLength = [str length];
while (textPos < strLength) {
//设置路径
CTFrameRef frame = CTFramesetterCreateFrame(framesetterRef, CFRangeMake(textPos, ), path, NULL);
CFRange frameRange = CTFrameGetVisibleStringRange(frame);
NSRange range = NSMakeRange(frameRange.location, frameRange.length); // [arrayPagingRange addObject:[NSValue valueWithRange:range]];
// [arrayPagingStr addObject:[str attributedSubstringFromRange:range]]; CoreTextDataModel *model = [[CoreTextDataModel alloc]init];
model.ctFrame = frame;
model.range = range;
model.content = [str attributedSubstringFromRange:range];
model.arrayImage = [self wkj_arrayCoreTextImgRect:[self wkj_arrayCoreTextImg:arrayParagraph range:range] cfFrame:frame]; [arrayCoretext addObject:model];
//移动
textPos += frameRange.length;
CFRelease(frame);
}
CGPathRelease(path);
CFRelease(framesetterRef);
// return arrayPagingStr;
return arrayCoretext;
}
///获取每页区域内存在的图片
+ (NSArray *)wkj_arrayCoreTextImg:(NSArray *)arrayParagraph
range:(NSRange)range{
NSMutableArray *array = [NSMutableArray array]; for (CoreTextParagraphSource *paragraph in arrayParagraph) {
if (paragraph.type == CoreTextSourceTypeTxt) {
continue;
} if (paragraph.imgData.position >= range.location &&
paragraph.imgData.position < (range.location + range.length)) {
[array addObject:paragraph.imgData];
}
} return array;
}
///获取每个区域内存在的图片位置
+ (NSArray *)wkj_arrayCoreTextImgRect:(NSArray *)arrayCoreTextImg cfFrame:(CTFrameRef)frameRef{
NSMutableArray *arrayImgData = [NSMutableArray array]; if (arrayCoreTextImg.count == ) {
return arrayCoreTextImg;
}
NSArray *lines = (NSArray *)CTFrameGetLines(frameRef);
NSUInteger lineCount = [lines count];
CGPoint lineOrigins[lineCount];
CTFrameGetLineOrigins(frameRef, CFRangeMake(, ), lineOrigins);
int imgIndex = ;
CoreTextImgSource * imageData = arrayCoreTextImg[];
for (int i = ; i < lineCount; ++i) { CTLineRef line = (__bridge CTLineRef)lines[i];
NSArray * runObjArray = (NSArray *)CTLineGetGlyphRuns(line);
for (id runObj in runObjArray) {
CTRunRef run = (__bridge CTRunRef)runObj;
NSDictionary *runAttributes = (NSDictionary *)CTRunGetAttributes(run);
CTRunDelegateRef delegate = (__bridge CTRunDelegateRef)[runAttributes valueForKey:(id)kCTRunDelegateAttributeName];
if (delegate == nil) {///如果代理为空,则未找到设置的空白字符代理
continue;
} CoreTextImgSource * metaImgSource = CTRunDelegateGetRefCon(delegate);
if (![metaImgSource isKindOfClass:[CoreTextImgSource class]]) {
continue;
} CGRect runBounds;
CGFloat ascent;
CGFloat descent;
runBounds.size.width = CTRunGetTypographicBounds(run, CFRangeMake(, ), &ascent, &descent, NULL);
runBounds.size.height = ascent + descent; CGFloat xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, NULL);
runBounds.origin.x = lineOrigins[i].x + xOffset;
runBounds.origin.y = lineOrigins[i].y;
runBounds.origin.y -= descent; CGPathRef pathRef = CTFrameGetPath(frameRef);
CGRect colRect = CGPathGetBoundingBox(pathRef); CGRect delegateBounds = CGRectOffset(runBounds, colRect.origin.x, colRect.origin.y); imageData.imagePosition = delegateBounds;
CoreTextImgSource *img = imageData;
[arrayImgData addObject:img];
imgIndex++;
if (imgIndex == arrayCoreTextImg.count) {
imageData = nil;
break;
} else {
imageData = arrayCoreTextImg[imgIndex];
}
} if (imgIndex == arrayCoreTextImg.count) {
break;
} } return arrayImgData; } ///获取属性字符串字典
+ (NSMutableDictionary *)wkj_attributes{
CGFloat fontSize = [BookThemeManager sharedManager].fontSize;
CTFontRef fontRef = CTFontCreateWithName((CFStringRef)@"ArialMT", fontSize, NULL);
///行间距
CGFloat lineSpacing = [BookThemeManager sharedManager].lineSpace;
///首行缩进
CGFloat firstLineHeadIndent = [BookThemeManager sharedManager].firstLineHeadIndent;
///段落间距
CGFloat paragraphSpacing = [BookThemeManager sharedManager].ParagraphSpacing;
//换行模式
CTLineBreakMode lineBreak = kCTLineBreakByCharWrapping;
const CFIndex kNumberOfSettings = ;
CTParagraphStyleSetting theSettings[kNumberOfSettings] = {
///行间距
{ kCTParagraphStyleSpecifierLineSpacingAdjustment, sizeof(CGFloat), &lineSpacing },
{ kCTParagraphStyleSpecifierMaximumLineSpacing, sizeof(CGFloat), &lineSpacing },
{ kCTParagraphStyleSpecifierMinimumLineSpacing, sizeof(CGFloat), &lineSpacing },
///首行缩进
{ kCTParagraphStyleSpecifierFirstLineHeadIndent, sizeof(CGFloat), &firstLineHeadIndent },
///换行模式
{ kCTParagraphStyleSpecifierLineBreakMode, sizeof(CTLineBreakMode), &lineBreak },
///段落间距
{ kCTParagraphStyleSpecifierParagraphSpacing, sizeof(CGFloat), ¶graphSpacing }
}; CTParagraphStyleRef theParagraphRef = CTParagraphStyleCreate(theSettings, kNumberOfSettings); UIColor * textColor = [BookThemeManager sharedManager].textColor; NSMutableDictionary * dict = [NSMutableDictionary dictionary];
dict[(id)kCTForegroundColorAttributeName] = (id)textColor.CGColor;
dict[(id)kCTFontAttributeName] = (__bridge id)fontRef;
dict[(id)kCTParagraphStyleAttributeName] = (__bridge id)theParagraphRef;
CFRelease(theParagraphRef);
CFRelease(fontRef);
return dict;
} ///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
+ (NSAttributedString *)wkj_loadChapterParagraphArray:(NSArray *)arrayArray{ NSMutableAttributedString *resultAtt = [[NSMutableAttributedString alloc] init]; for (CoreTextParagraphSource *paragraph in arrayArray) {
if (paragraph.type == CoreTextSourceTypeTxt) {///文本
NSAttributedString *txtAtt = [self wkj_parseContentFromCoreTextParagraph:paragraph];
[resultAtt appendAttributedString:txtAtt];
}
else if (paragraph.type == CoreTextSourceTypeImage){///图片
paragraph.imgData.position = resultAtt.length;
NSAttributedString *imageAtt = [self wkj_parseImageFromCoreTextParagraph:paragraph];
[resultAtt appendAttributedString:imageAtt];
}
} return resultAtt;
} ///根据段落文本内容获取 AttributedString
+ (NSAttributedString *)wkj_parseContentFromCoreTextParagraph:(CoreTextParagraphSource *)paragraph{
NSMutableDictionary *attributes = [self wkj_attributes];
return [[NSAttributedString alloc] initWithString:paragraph.txtData.content attributes:attributes];
} /////根据段落图片内容获取 AttributedString 空白占位符
+ (NSAttributedString *)wkj_parseImageFromCoreTextParagraph:(CoreTextParagraphSource *)paragraph{ CTRunDelegateCallbacks callbacks;
memset(&callbacks, , sizeof(CTRunDelegateCallbacks));
callbacks.version = kCTRunDelegateVersion1;
callbacks.getAscent = ascentCallback;
callbacks.getDescent = descentCallback;
callbacks.getWidth = widthCallback;
CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(paragraph.imgData)); // 使用0xFFFC作为空白的占位符
unichar objectReplacementChar = 0xFFFC;
NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:];
NSMutableDictionary * attributes = [self wkj_attributes];
// attributes[(id)kCTBackgroundColorAttributeName] = (id)[UIColor yellowColor].CGColor;
NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:content attributes:attributes];
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(, ),
kCTRunDelegateAttributeName, delegate);
CFRelease(delegate);
return space;
} //+ (NSAttributedString *)wkj_NewlineAttributes{
// CTRunDelegateCallbacks callbacks;
// memset(&callbacks, 0, sizeof(CTRunDelegateCallbacks));
// callbacks.version = kCTRunDelegateVersion1;
// callbacks.getAscent = ascentCallback;
// callbacks.getDescent = descentCallback;
// callbacks.getWidth = widthCallback;
// CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(paragraph));
//
// // 使用0xFFFC作为空白的占位符
// unichar objectReplacementChar = 0xFFFC;
// NSString * content = [NSString stringWithCharacters:&objectReplacementChar length:1];
// NSMutableDictionary * attributes = [self wkj_attributes];
// // attributes[(id)kCTBackgroundColorAttributeName] = (id)[UIColor yellowColor].CGColor;
// NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:content attributes:attributes];
// CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1),
// kCTRunDelegateAttributeName, delegate);
// CFRelease(delegate);
// return space;
//} static CGFloat ascentCallback(void *ref){
// return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"height"] floatValue];
CoreTextImgSource *refP = (__bridge CoreTextImgSource *)ref;
return refP.height;
} static CGFloat descentCallback(void *ref){
return ;
} static CGFloat widthCallback(void* ref){
// return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue]; CoreTextImgSource *refP = (__bridge CoreTextImgSource *)ref;
return refP.width;
} @end
添加好CoreTextDataTools类之后,就可以通过 wkj_downloadBookImage 方法来下载图片;图片下载完之后,就可以对每页显示的内容区域进行分页;划线和涂色的一些方法在上一篇中已提到;
///获取测试数据源文件
NSString *path = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
///获取该章所有段落内容
NSArray *arrayParagraphSource = [CoreTextSource arrayReaolveChapterHtmlDataWithFilePath:path];
///下载该章中的所有图片
[CoreTextDataTools wkj_downloadBookImage:arrayParagraphSource];
///根据一个章节的所有段落内容,来生成 AttributedString 包括图片
NSAttributedString *att = [CoreTextDataTools wkj_loadChapterParagraphArray:arrayParagraphSource];
///给章所有内容分页 返回 CoreTextDataModel 数组
NSArray *array = [CoreTextDataTools wkj_coreTextPaging:att textArea:CGRectMake(, , self.view.bounds.size.width - , self.view.bounds.size.heigh t- ) arrayParagraphSource:arrayParagraphSource];