前言
19年做了一個(gè)小說閱讀器搔耕,特此介紹閱讀器設(shè)計(jì)谭溉,還有實(shí)現(xiàn)過程中的一些坑。
正文
一芋绸、閱讀器整體設(shè)計(jì)
閱讀器的基本功能是文字展示肝劲、翻頁滾動(dòng)迁客,以及目錄展示、進(jìn)度切換辞槐、調(diào)整字號和主題切換等掷漱,擴(kuò)展功能包括文本選擇和復(fù)制,可能還會(huì)有第三方分享的定制化界面等榄檬。
通過整理以上功能卜范,我們可以把整個(gè)閱讀器的功能分為幾個(gè)方面:
1、數(shù)據(jù)處理:將原書籍?dāng)?shù)據(jù)進(jìn)行處理鹿榜,得到能夠展示的文本以及相應(yīng)的目錄數(shù)據(jù)海雪;
2、文本展示:用CoreText處理文本舱殿,將其劃分為多頁數(shù)據(jù)奥裸,進(jìn)行展示處理;
3沪袭、交互響應(yīng):翻頁邏輯刺彩、目錄操作、字號調(diào)整、背景切換等交互處理创倔;
在設(shè)計(jì)以上功能的時(shí)候嗡害,需要考慮后續(xù)的圖文混排、文本選中等變化畦攘,選擇較為靈活的方案霸妹。
圍繞左右滑動(dòng)和分頁展示、數(shù)據(jù)加載知押,簡易的流程圖如下
總共會(huì)有四個(gè)層級:
- 交互層:處理左右滑動(dòng)的事件以及正常的用戶操作響應(yīng)叹螟;(VC處理,view在渲染層)
- 邏輯層:網(wǎng)絡(luò)數(shù)據(jù)請求台盯、數(shù)據(jù)格式轉(zhuǎn)換和布局排版的計(jì)算罢绽;
- 數(shù)據(jù)層:對數(shù)據(jù)進(jìn)行封裝,主要包括業(yè)務(wù)數(shù)據(jù)静盅、用戶設(shè)置數(shù)據(jù)良价、排版數(shù)據(jù);
- 渲染層:目錄展示蒿叠、各種交互view的顯示明垢、根據(jù)排版結(jié)果進(jìn)行渲染;
SSLayoutManager + SSConfigData + SSChapterData = SSPageData
布局管理器 + 用戶設(shè)置數(shù)據(jù) + 章節(jié)數(shù)據(jù) = 分頁后的每頁排版結(jié)果
整個(gè)結(jié)構(gòu)圖如下
二市咽、CoreText相關(guān)問題
CTFramesetter是NSAttributedString的CF對象痊银,可以直接強(qiáng)轉(zhuǎn);
CTFrame是排版數(shù)據(jù)施绎,由CTFramesetter生成溯革;
NSAttributedString是常用的富文本字符串類;
CTLine是CTFrame中的一行文本谷醉、CTRun是CTLine中有相同屬性的連續(xù)字形致稀;
閱讀器的排版基于CoreText,通過章節(jié)文本數(shù)據(jù)SSChapterData和用戶設(shè)置SSConfigData孤紧,可以生成帶格式的富文本NSAttributeString豺裆;通過CoreText將富文本轉(zhuǎn)化成多個(gè)SSLayoutPageData,每個(gè)對象中都有一個(gè)CTFrameRef号显,代表一頁的排版結(jié)果臭猜;最終SSPageView將其CTFrameRef渲染到到屏幕上。
1押蚤、CTLine
CTFrameRef是我們生成的排版數(shù)據(jù)蔑歌,通過CTFrameGetLines
這個(gè)函數(shù)可以拿到NSArray數(shù)組,第0個(gè)元素是第1行揽碘,根據(jù)行數(shù)可以獲取到CTLineRef次屠;
CTFrameGetLineOrigins
這個(gè)函數(shù)可以直接獲取對應(yīng)line的位置园匹;
CGPoint insertPoint;
CTFrameGetLineOrigins(frameRef, CFRangeMake(insertLineIndex + 1, 1), &insertPoint);
獲取的行位置信息有2個(gè)注意事項(xiàng):
1、CoreText的坐標(biāo)系是左下角原點(diǎn)劫灶,所以對于點(diǎn)(0裸违, 100)是距離底部100的位置;
2本昏、行的起始點(diǎn)不是行真實(shí)的起點(diǎn)供汛,而是下圖的Origin位置;
從上圖可以看到涌穆,origin(原點(diǎn))的位置是在descent上面怔昨,也即是我們通過CoreText指定大小的時(shí)候。
非常重要的三個(gè)屬性:ascent宿稀、descent趁舀、width
static CGFloat ascentCallback(void * refCon){
SSEmptyLayoutData *data = (__bridge SSEmptyLayoutData *)refCon;
return data.size.height;
}
static CGFloat descentCallback(void * refCon){
return 50;
}
static CGFloat widthCallback(void * refCon){
SSEmptyLayoutData *data = (__bridge SSEmptyLayoutData *)refCon;
return data.size.width;
}
對照到下圖,綠色是原點(diǎn)祝沸,ascent矮烹、width、desent分別如圖所示奋隶。
2擂送、圖文混排
圖文混排的過程中悦荒,CoreText會(huì)回調(diào)我們某個(gè)字符的寬高唯欣,但是如果不注意代碼會(huì)出現(xiàn)異常:
打出crash堆棧如下:
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x7a0090020)
frame #0: 0x0000000111350faa libobjc.A.dylib`objc_retain + 10
* frame #1: 0x000000010a4fc566 TTReading`ascentCallback(ref=0x0000600003592e40) at SSLayoutManager.m:14
frame #2: 0x000000010e5551a6 CoreText`TDelegateRun::TDelegateRun(CTRun const*) + 102
frame #3: 0x000000010e4a03b6 CoreText`TGlyphEncoder::EncodeChars(CFRange, TAttributes const&, TGlyphEncoder::Fallbacks) + 518
frame #4: 0x000000010e4b8b2a CoreText`TTypesetterAttrString::Initialize(__CFAttributedString const*) + 238
frame #5: 0x000000010e4b8a2e CoreText`TTypesetterAttrString::TTypesetterAttrString(__CFAttributedString const*, __CFDictionary const*) + 176
frame #6: 0x000000010e4b4422 CoreText`CTFramesetterCreateWithAttributedString + 91
出現(xiàn)問題的代碼如下:
CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(dict)); // Crash
通過堆棧可以發(fā)現(xiàn)搬味,是在ascentCallback
函數(shù)訪問參數(shù)時(shí)出現(xiàn)的內(nèi)存異常境氢;
經(jīng)過分析和多次嘗試,發(fā)現(xiàn)以下這段代碼是正常的:
CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, (__bridge void *)(@"height")); // OK
再回過頭來分析碰纬,應(yīng)該是dict變量在函數(shù)執(zhí)行過后被釋放萍聊,導(dǎo)致ascentCallback
回調(diào)時(shí)發(fā)生異常;
此處記起ARC相關(guān)悦析,加深關(guān)于__bridge的理解和記憶寿桨。
3、格式轉(zhuǎn)換
網(wǎng)上的小說很多是html格式的文本强戴,如下:
HTML的字符串可以通過系統(tǒng)API轉(zhuǎn)成NSAttributedString亭螟,再通過其string屬性,可以訪問到NSString骑歹;
/**
* html字符串轉(zhuǎn)富文本
*/
- (NSAttributedString *)htmlStrConvertToAttributeStr:(NSString *)htmlStr {
return [[NSAttributedString alloc] initWithData:[htmlStr dataUsingEncoding:NSUnicodeStringEncoding]
options:@{NSDocumentTypeDocumentAttribute:NSHTMLTextDocumentType}
documentAttributes:nil
error:nil];
}
這里的代碼配合UIPageViewController會(huì)有偶現(xiàn)的Crash预烙,但是出現(xiàn)的概率是千分之幾;如果想完全避免這個(gè)crash可以換用其他解析庫道媚。
4扁掸、分頁計(jì)算
分頁計(jì)算的核心是拿到NSAttributedString和pageSize翘县,按照頁面大小進(jìn)行排版,分別得到每頁的字符串范圍谴分,最終以NSRange的方式返回锈麸,舉例:
(
"NSRange: {0, 34}",
"NSRange: {34, 36}",
"NSRange: {70, 40}",
"NSRange: {110, 39}",
"NSRange: {149, 35}",
"NSRange: {184, 40}",
"NSRange: {224, 37}",
"NSRange: {261, 38}",
"NSRange: {299, 3}"
)
以下這段代碼可以是具體的分割邏輯:
- (NSArray *)pagingContentWithAttributeStr:(NSAttributedString *)attributeStr pageSize:(CGSize)pageSize {
NSMutableArray<NSValue *> *resultRange = [NSMutableArray array]; // 返回結(jié)果數(shù)組
CGRect rect = CGRectMake(0, 0, pageSize.width, pageSize.height); // 每頁的顯示區(qū)域大小
NSUInteger curIndex = 0; // 分頁起點(diǎn),初始為第0個(gè)字符
while (curIndex < attributeStr.length) { // 沒有超過最后的字符串牺蹄,表明至少剩余一個(gè)字符
NSUInteger maxLength = MIN(1000, attributeStr.length - curIndex); // 1000為最小字體的每頁最大數(shù)量掐隐,減少計(jì)算量
NSAttributedString * subString = [attributeStr attributedSubstringFromRange:NSMakeRange(curIndex, maxLength)]; // 截取字符串
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef) subString); // 根據(jù)富文本創(chuàng)建排版類CTFramesetterRef
UIBezierPath * bezierPath = [UIBezierPath bezierPathWithRect:rect];
CTFrameRef frameRef = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), bezierPath.CGPath, NULL); // 創(chuàng)建排版數(shù)據(jù),第個(gè)參數(shù)的range.length=0表示放字符直到區(qū)域填滿
CFRange visiableRange = CTFrameGetVisibleStringRange(frameRef); // 獲取當(dāng)前可見的字符串區(qū)域
NSRange realRange = {curIndex, visiableRange.length}; // 當(dāng)頁在原始字符串中的區(qū)域
[resultRange addObject:[NSValue valueWithRange:realRange]]; // 記錄當(dāng)頁結(jié)果
curIndex += realRange.length; //增加索引
CFRelease(frameRef);
CFRelease(frameSetter);
};
return resultRange;
}
5钞馁、跨頁首行縮進(jìn)異常
設(shè)置了首行縮進(jìn)后虑省,每段文字的第一行會(huì)空出兩個(gè)字符左右的大小僧凰;
但是在某段文字被分在兩個(gè)頁時(shí)探颈,第二頁因?yàn)槭切缕鸬囊豁摚瑫?huì)識(shí)別為新的一段训措!
解決方案1伪节、換行替換為換行+空格,然后取消首行縮進(jìn)绩鸣;
解決方案2怀大、每頁在開始時(shí),判斷上頁最后一個(gè)字符是否為換行符呀闻,再?zèng)Q定是否取消首行縮進(jìn)化借;
if (curIndex > 0 && [attributeStr.string characterAtIndex:curIndex - 1] != '\n') {
NSMutableParagraphStyle *style = [attributeStr attribute:NSParagraphStyleAttributeName atIndex:curIndex effectiveRange:NULL];
NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
paragraphStyle.firstLineHeadIndent = 0;
paragraphStyle.lineBreakMode = NSLineBreakByCharWrapping;
paragraphStyle.lineSpacing = style.lineSpacing;
paragraphStyle.paragraphSpacing = style.paragraphSpacing;
paragraphStyle.alignment = NSTextAlignmentJustified;
[attributeStr addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:NSMakeRange(curIndex, 1)];
}
6、最后一行排版異常
排版過程中往文字最后插入了一個(gè)特殊空白字符捡多,結(jié)果排版如下:
排版的規(guī)則是兩端對齊(最后一行會(huì)自然靠左)蓖康,因?yàn)椴迦肓颂厥庾址澳戤?dāng)然也是明白”這段字被識(shí)別為倒數(shù)第二行垒手,觸發(fā)了兩端對齊的邏輯蒜焊;
那么可以在末尾的時(shí)候補(bǔ)齊一個(gè)'\n'符號;
CFRange range = CTLineGetStringRange(line);
NSUInteger insertIndex = curIndex + range.location + range.length;
if (insertIndex >= attributeStr.length) { // 避免最后一行的特殊情況處理
[attributeStr insertAttributedString:[[NSAttributedString alloc] initWithString:@"\n"] atIndex:insertIndex];
insertIndex = attributeStr.length;
}
三科贬、UIPageViewController相關(guān)問題
1泳梆、ViewController相關(guān)
UIPageViewController 在手動(dòng)設(shè)置vc的時(shí)候,非常容易crash榜掌;
以loadingVC為例优妙,在展示vc后,會(huì)同步去加載數(shù)據(jù)唐责;
當(dāng)數(shù)據(jù)會(huì)回調(diào)后鳞溉,此時(shí)無法使用新的vc去替換;
所以總體的設(shè)計(jì)中鼠哥,vc在賦值給UIPageViewController之后熟菲,就不應(yīng)該修改看政;
延伸出來的翻頁邏輯優(yōu)化
UIPageVC在使用過程中(動(dòng)畫過程中),不可調(diào)用這個(gè)方法抄罕,否則滑動(dòng)的手勢會(huì)取消允蚣,出現(xiàn)閃動(dòng)的效果。
- (void)pageViewController:(UIPageViewController *)pageViewController didFinishAnimating:(BOOL)finished previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers transitionCompleted:(BOOL)completed {
if (!completed && previousViewControllers && [previousViewControllers[0] isKindOfClass:[SSReadingBasePageViewController class]]) {
SSPageControllData *lastData = [(SSReadingBasePageViewController *)previousViewControllers[0] pageControllData];
SSPageControllData *pageData = [self.pageControllManager onLoadingReadyWithChapterId:loadingVC.pageControllData.loadingData.loadingChapterId loadingData:loadingVC.pageControllData.loadingData];
[self setPageVCWithPageControllData:pageData isNext:YES];
}
UIPageViewController另外的問題是無法監(jiān)聽當(dāng)前狀態(tài)呆贿,判斷當(dāng)前是否處于翻頁過程嚷兔,這對很多擴(kuò)展邏輯進(jìn)行了限制。
2做入、偶現(xiàn)Crash -Invalid parameter not satisfying: [views count] == 3'
該問題為偶現(xiàn)Crash冒晰,由stackoverflow上面的某回答建議:
- set dataSource before calling setViewControllers method
- use setViewControllers method without animation (animated: false)
- set dataSource to nil for single page mode
可以減少這種情況的出現(xiàn),但是無法杜絕竟块。
從簡書上另外一個(gè)開發(fā)者的介紹壶运,UIPageViewController存在多個(gè)容易出現(xiàn)的Crash,UIPageViewController好用但是不太穩(wěn)定浪秘。
3蒋情、翻頁數(shù)據(jù)異常
UIPageViewController在翻頁的時(shí)候會(huì)請求下一頁數(shù)據(jù),我們通過UIViewController封裝好對應(yīng)的數(shù)據(jù)和視圖耸携,直接回傳一個(gè)VC棵癣;
但是當(dāng)用戶頻繁滑動(dòng)并在滑動(dòng)動(dòng)畫未完成就觸發(fā)點(diǎn)擊進(jìn)入下一頁的邏輯時(shí),會(huì)出現(xiàn)數(shù)據(jù)展示錯(cuò)誤的情況夺衍。
對翻頁邏輯進(jìn)行整理狈谊,有滑動(dòng)和點(diǎn)擊兩種方式。點(diǎn)擊的時(shí)候會(huì)同步更新當(dāng)前數(shù)據(jù)源為下一頁刷后,所以即使點(diǎn)擊很快的畴,也不會(huì)出現(xiàn)數(shù)據(jù)源異常的情況渊抄。
問題在于滑動(dòng)切換時(shí)尝胆,何時(shí)把數(shù)據(jù)源更新為下一頁?
由于UIPageViewController的局限护桦,較好的一種方案是在開始滑動(dòng)時(shí)就把數(shù)據(jù)源更新含衔,最后如果用戶取消翻頁,則將數(shù)據(jù)源更新為原來的頁面二庵。
4贪染、UIPageViewControllerTransitionStylePageCurl翻頁模式下Crash
當(dāng)UIPageViewController需要背面的VC時(shí),會(huì)向delegate請求催享,此時(shí)需要返回對應(yīng)的BackVC杭隙,否則出現(xiàn)數(shù)據(jù)展示異常;
通過setViewControllers
方法手動(dòng)切換界面時(shí)因妙,如果設(shè)置animated為YES痰憎,則必須傳入兩個(gè)vc否則會(huì)出現(xiàn)Crash票髓。
5、手勢沖突
UIPageViewController是一個(gè)容器铣耘,上面會(huì)放置真正用于顯示的VC洽沟,需要注意VC不能存在全屏的view,否則手勢無法傳到UIPageViewController蜗细,會(huì)出現(xiàn)無法左右滑動(dòng)的情況裆操;
總結(jié)
19年花了很多時(shí)間在這上面,文章介紹了大部分遇到的問題和解決方案炉媒,寫了一個(gè)簡單的demo踪区,地址見GitHub。
篇幅和時(shí)間所限吊骤,如果有具體的問題可以聯(lián)系交流朽缴。