前言 剛接手電筒子書項目時,和安卓開發者pt Cai老師【aipiti Cai,一個我很敬佩很資深的開發工程師,設計領域:c++、Java、安卓、QT等】共同商議了一下,因為項目要做要同步,移動端【手機端】和PC【電腦端】的同步問題,讓我們無法決定該用那種方式去呈現電子書,因為PC要展示的電子書有網路 ...
前言
剛接手電筒子書項目時,和安卓開發者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 = 1, ///圖片 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; @endView Code
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 - 30)) { CGFloat ratioHW = image.height/image.width; image.width = Scr_Width - 30; 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; } @endView Code
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; @endView Code
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; } } @endView Code
CoreTextDataTools.h
///圖片下載 + (void)wkj_downloadBookImage:(NSArray *)arrayParagraph; ///分頁 + (NSArray *)wkj_coreTextPaging:(NSAttributedString *)str textArea:(CGRect)textFrame arrayParagraphSource:(NSArray *)arrayParagraph; ///根據一個章節的所有段落內容,來生成 AttributedString 包括圖片 + (NSAttributedString *)wkj_loadChapterParagraphArray:(NSArray *)arrayArray;View Code
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 = 0; NSUInteger strLength = [str length]; while (textPos < strLength) { //設置路徑 CTFrameRef frame = CTFramesetterCreateFrame(framesetterRef, CFRangeMake(textPos, 0), 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 == 0) { return arrayCoreTextImg; } NSArray *lines = (NSArray *)CTFrameGetLines(frameRef); NSUInteger lineCount = [lines count]; CGPoint lineOrigins[lineCount]; CTFrameGetLineOrigins(frameRef, CFRangeMake(0, 0), lineOrigins); int imgIndex = 0; CoreTextImgSource * imageData = arrayCoreTextImg[0]; for (int i = 0; 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(0, 0), &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 = 6; 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, 0, 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: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; } //+ (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 0; } static CGFloat widthCallback(void* ref){ // return [(NSNumber*)[(__bridge NSDictionary*)ref objectForKey:@"width"] floatValue]; CoreTextImgSource *refP = (__bridge CoreTextImgSource *)ref; return refP.width; } @endView Code
添加好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(5, 5, self.view.bounds.size.width - 10, self.view.bounds.size.heigh t- 120) arrayParagraphSource:arrayParagraphSource];
4.效果