小說閱讀器的設(shè)計(jì)和實(shí)現(xiàn)

前言

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上面的某回答建議:

  1. set dataSource before calling setViewControllers method
  2. use setViewControllers method without animation (animated: false)
  3. 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)系交流朽缴。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市水援,隨后出現(xiàn)的幾起案子密强,更是在濱河造成了極大的恐慌,老刑警劉巖蜗元,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件或渤,死亡現(xiàn)場離奇詭異,居然都是意外死亡奕扣,警方通過查閱死者的電腦和手機(jī)薪鹦,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來惯豆,“玉大人池磁,你說我怎么就攤上這事】蓿” “怎么了地熄?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長芯杀。 經(jīng)常有香客問我端考,道長,這世上最難降的妖魔是什么揭厚? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任却特,我火速辦了婚禮,結(jié)果婚禮上筛圆,老公的妹妹穿的比我還像新娘裂明。我一直安慰自己,他們只是感情好太援,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布闽晦。 她就那樣靜靜地躺著轰绵,像睡著了一般。 火紅的嫁衣襯著肌膚如雪尼荆。 梳的紋絲不亂的頭發(fā)上左腔,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天,我揣著相機(jī)與錄音捅儒,去河邊找鬼液样。 笑死,一個(gè)胖子當(dāng)著我的面吹牛巧还,可吹牛的內(nèi)容都是我干的鞭莽。 我是一名探鬼主播,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼麸祷,長吁一口氣:“原來是場噩夢啊……” “哼澎怒!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起阶牍,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤喷面,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后走孽,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體惧辈,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年磕瓷,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了盒齿。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,094評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡困食,死狀恐怖边翁,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情硕盹,我是刑警寧澤符匾,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站莱睁,受9級特大地震影響待讳,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜仰剿,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望痴晦。 院中可真熱鬧南吮,春花似錦、人聲如沸誊酌。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至涂邀,卻和暖如春瘟仿,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背比勉。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工劳较, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人浩聋。 一個(gè)月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓观蜗,卻偏偏與公主長得像,于是被迫代替她去往敵國和親衣洁。 傳聞我的和親對象是個(gè)殘疾皇子墓捻,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,828評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 我有了新生活 身無病痛,心有余力 可如今坊夫,我再也寫不出詩 我再也沒有時(shí)間讀書 甚至沒有時(shí)間來思考和想你 我離想要的...
    迷惘之鄉(xiāng)閱讀 186評論 0 0
  • 2017年8月20日 1.感恩爸媽的養(yǎng)育之恩砖第。 2.感恩兒子讓我享受到幸福,覺察到與他溝通時(shí)的負(fù)面語言較多环凿,要積極...
    馮梓源閱讀 155評論 0 0
  • 時(shí)光悄然旋轉(zhuǎn)拷邢,一不經(jīng)意間2018就快走到了尾聲袱院,不管這一年是奮力拼搏或是隨意揮霍,歲月的年輪已經(jīng)軋下了一道重重的痕...
    不服輸?shù)拇笫?/span>閱讀 261評論 0 0
  • String類概述 String 類被final修飾悔雹,無子類复哆,不可被復(fù)寫,對String類的任何改變腌零,都會(huì)返回一個(gè)...
    哎呀啊噢閱讀 383評論 0 0