结构拆解
首先需要定位到漫画阅读的那一个 ViewController。得益于 Aidoku 良好的命名方式和代码结构,定位这个并不困难,这里不多说。
找到了对应的 VC ReaderViewController
后,需要逐渐分析代码的结构。项目作者将所有布局相关的代码都放到了 configure() 中,在这个方法中找到子 View 的添加方法,并定位到关键的漫画显示 VC。漫画显示 VC 有两种情况,ReaderPagedViewController
和ReaderWebtoonViewController
,这里需要选择一个进行查看,所以我将这两个 VC 的代码交给 AI 进行查看,并让它分析其作用。
GPT 无法阅读这么多的上下文,所以我这里选择了另一个 LLM 模型,也就是月之暗面的 KimiChat。相较于 GPT 的 32K 数据,月之暗面支持 200K ,足够使用了。
根据 AI 给出的回答,ReaderPagedViewController
常见的翻页方式,ReaderWebtoonViewController
是网络漫画也就是条漫常用的上下滑动方式。这里选择前者进行继续研究。
按照相同的思路,我继续深挖页面的结构,最终结论如下:
虚线代表二选一。
在 ReaderPageVC 和 ReaderDoublePageVC 这一层会有一些区别,ReaderPageVC 可以直接组成上一层的界面,也可以通过 ReaderDoublePageVC 将两个 ReaderPageVC 包装后再组成上一层。
接下来需要做的事情,则是分析每个 VC 具体的实现方式,方便后续调整为适合自己使用的方式。
View 和 VC 文件分析
按照从下往上的顺序,逐个分析代码实现。
1. ReaderPageView
从 configure() 来看,这个文件内含一个 Loading 和 一张图片,也就是用来加载具体页面的图片的。
需要先要搞清楚这个 View 是如何加载图片,也就是看上层调用这个文件中的哪个方法。在ZoomableScrollView
中没有找到,所以在更上一层的 ReaderPageVC 里找。最终定位到 setPage 这个方法,接下来就从这个方法出发研究图片的加载逻辑。
...
func setPage(_ page: Page, sourceId: String? = nil) async -> Bool {
if sourceId != nil {
self.sourceId = sourceId
}
if let urlString = page.imageURL, let url = URL(string: urlString) {
return await setPageImage(url: url, sourceId: self.sourceId)
} else if let base64 = page.base64 {
return await setPageImage(base64: base64, key: page.hashValue)
} else {
return false
}
}
...
接下来使用流程图展示图片的加载逻辑。
通过 setPage,传入一个 Page 对象,根据 page 中的 imageURL 或 base64 数据,调用 setPageImage 方法。
setPageImage 需要使用 ImageRequest 请求图片,但是先判断 imageTask 是否存在了任务。imageTask 是 View 里的一个属性,用来保存当前的任务。
如果存在
如果是正在运行中,调用 completion
如果是已经完成
但是 imageView 为空,那么把任务再发起(猜测是再发起一次,因为任务是 complete 但是图没加载)
如果不为空,那么直接返回成功
如果是已取消,和 imageView 为空是一样的,重新发起。
如果不存在,需要初始化这个 request 然后赋值 imageTask 开始任务。以 url 类型的 page 对象为例,一个 imageTask 包含两部分: url 请求和 processors 预处理器,urlRequest 似乎是从一个单例模式的 SourceManager 中通过传入的 url 快速初始化的,而 processors 则是根据设置中的开关决定是否启用,对图像进行压缩和边缘多余部分裁剪。
总之 setPage 这个方法完成后,可以保证 ReaderPageView 中的 imageView 有内容或者有待运行的 imageTask 内容来加载图片。
我希望查看一下这个 View 的效果来验证我的分析,但是又不希望单独开一个项目,所以我使用了 UIViewRepresentable 协议在 SwiftUI 中显示这个 View。
我先暂时注释掉其他的方法避免不必要的错误,并新增了一个 setFakePageImage() 方法用来初始化一张占位用的图片。
然后使用
struct ReaderPageViewWrapper: UIViewRepresentable{
func makeUIView(context: Context) -> ReaderPageView {
let readerPageView = ReaderPageView()
readerPageView.setFakePageImage() // 初始化图片
return readerPageView
}
func updateUIView(_ uiView: ReaderPageView, context: Context) {
uiView.fixImageSize() // 根据需要调整图片尺寸
}
}
这样就可以在 SwiftUI 中使用 ReaderPageViewWrapper 查看这个 View。后面也会用到这个方法来查看其他 View 和 ViewController 的效果。
2. ZoomableScrollView
从实现的效果上来看,继承了 UIKit 中的 UIScrollView ,实现了类似相册应用中一样的双击放大拖动查看细节的交互。
Aidoku 的作者也是从 Github 获得的代码,所以这个 View 解耦性很好ZoomableScrollView
的上层ReaderPageViewController
也是通过直接调用ReaderPageView
的 setPage 加载漫画图片,ZoomableScrollView
只是单独的一个工具层。
3. ReaderInfoPageView
用于显示章节之间的过渡页面。
源代码,是一个很纯粹的布局文件,通过 init 直接写布局,并通过一个 updateLabelText() 方法更新文字,不像 ReaderPageView 中还有加载图片的逻辑。
class ReaderInfoPageView: UIView {
...(省略)...
let noChapterLabel = UILabel()
let stackView = UIStackView()
let topChapterLabel = UILabel()
let topChapterTitleLabel = UILabel()
let bottomChapterLabel = UILabel()
let bottomChapterTitleLabel = UILabel()
init(type: ReaderInfoPageType, currentChapter: Chapter? = nil) {
self.type = type
self.currentChapter = currentChapter
super.init(frame: .zero)
backgroundColor = .systemBackground
stackView.distribution = .equalSpacing
stackView.axis = .vertical
stackView.spacing = 14
stackView.translatesAutoresizingMaskIntoConstraints = false
...(省略)...
updateLabelText()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateLabelText() {
guard let currentChapter = currentChapter else { return }
if let previousChapter = previousChapter {
topChapterLabel.text = NSLocalizedString("PREVIOUS_COLON", comment: "")
...(省略)...
noChapterLabel.isHidden = false
}
}
}
而且全部都使用了 UIKit 中的 UIStackView (可以理解成 VStack 和 HStack 等的前身),所以应该可以直接转为 SwiftUI 的布局。
4. ReaderPageViewController
页面由三部分构成,除了ReaderPageView
(ZoomableScrollView
是工具 View) 和ReaderInfoPageView
这两个根据条件显示的 View ,还有默认隐藏的 reload button。通过外部传入的 type 属性控制 View 的显示条件。
这个 VC 起到了承上启下的作用。
首先,之前的三个文件都是单独的 View,需要一个 VC 包装成页面;
其次,控制这一页应该显示具体的漫画页还是章节过渡页。
从逻辑上来说,可以将ReaderPageViewController
看成是漫画书中的一个最小的单元,姑且称之为”漫画页“。
5. ReaderDoublePageViewController
双页版本的ReaderPageVC
,不多分析。
6. ReaderPagedViewController
将”漫画页“们集合在一起,开始显示可翻页的”漫画书“。
其内部核心是一个UIPageViewController
,然后通过 delegate 和 dataSource 协议向这个UIPageViewController
提供数据。
至于UIPageViewController
中的数据是如何加载的,和ReaderPageView
一样的分析思路,看一下上层是如何让ReaderPagedVC
加载的数据。
在ReaderViewController
这层中,它调用了ReaderPagedVC
中的 setChapter 方法,传入了 chapter 对象和 startPage 数值。
在 setChapter 方法中,通过使用 Task 方式,在后台执行 loadChapter 方法。
loadChapter 方法中会调用 VC 内 viewModel 的 loadPages 方法加载页面,同时通过代理向上层传递页面数量。
加载完成后调用 loadPageControllers 将 viewModel 中的页面都初始化为 viewController,也就是下一层的ReaderPageVC
,这也是UIPageViewController
显示的内容。
然后使用 move 方法设置 VC 内部UIPageViewController
翻到对应的页面。
使用流程图表示如下:
继续看 loadPageControllers 方法。
使用一个数组 pageViewControllers 保存初始化的 pageVC。
初始化了 previousChapter ,也就是上一章节。当然这里需要通过代理的方式在上一层完成,因为 ReaderPagedViewController
一开始只接收了来自上层的一个章节信息,并不清楚章节前后的其他章节,这些信息需要从上一章节的一个 ChapterList 中获取。
接下来就是向 pageViewControllers 中填充数据了,按顺序来,应该先添加上一章节的最后一页。不过里面的内容还没有初始化。
然后是前一章节和这一章节的过渡页,过渡页的内容比较简单,所以直接初始化了所有信息。
然后就是章节所有的页面,使用一个 for 循环添加了对应数量的 pageVC,同样的也没有初始化。
压轴的是与下一章节之间的 Info 信息页,也直接初始化了。
大轴是下一章节的第一张图,作为预览信息,同样的没有初始化。
到这一步位置,所有的 PageVC 都初始化完成了,可以说壳子已经有了,还差往里面填内容。
填内容的行为,则发生在 UIPageViewController
的 delegate 中,在 didFinishAnimating 和 willTransitionTo ,也就是翻页动画结束后,和开始滑动到另一页时会调用。这一部分的内容需要单独开一个部分讲,这里我们先将当前的逻辑进行一下整理。
先确保能理解上述的逻辑,一开始由上层调用 setChapter 激活,然后初次初始化界面。
还有其他情况下,也会调用 setChapter ,翻页到前一章节/后一章节,或者是上层直接修改当前阅读的章节,就会更改 ReaderPagedViewController
中的 chapter ,此时需要重新调用 setChapter 来加载内容。
通过切换章节执行的代码和上层调用 setChapter 的基本差不多。不过在 loadPageControllers 中增加了一些代码,用来复用之前初始化的一些页面。
var firstPageController: ReaderPageViewController?
var lastPageController: ReaderPageViewController?
var nextChapterPreviewController: ReaderPageViewController?
var previousChapterPreviewController: ReaderPageViewController?
if chapter == previousChapter {
lastPageController = pageViewControllers.first
nextChapterPreviewController = pageViewControllers[2]
} else if chapter == nextChapter {
firstPageController = pageViewControllers.last
previousChapterPreviewController = pageViewControllers[pageViewControllers.count - 3]
}
如果新章节是前一章节,那么之前的 firstPC 会作为新的 nextCPC,preCPC 会作为新的 lastPC;如果新章节是后一章节,那么之前的 lastPC 会作为新的 preCPC ,nextCPC 会作为新的 firstPC。
通过这种方式,在初始化前一章节和后一章节时,避免了不必要内容的二次加载,节约了资源。
ReaderPagedViewController
中的主要逻辑就是这些,接下来就要分析 Delegate 和 DataSource 是如何控制页面的内容显示了。
Delegate 和 DataSource
Delegate
Delegate 监听了在翻页动画开始前和动画加载完成后这两个事件,并执行了相应的代码。
动画开始前调用的方法是:
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController])
它会对 pendingViewControllers ,也就是翻页后显示的 PageVC ,调用 setPage 加载内容。
动画加载后调用的方法是:
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool)
细分后有三种情况,
第一种是常规翻页,翻页动画完成后会加载当前页面前一张到后 x 张的图片,x 即希望的漫画预加载数量,在设置中可以设置;
第二种是翻到了过渡页,此时判断是否有对应的前/后一章节,如果有的话,调用 ViewModel 的预加载行为,并对前/后一章节的预览 PageVC ,也就是 preCPC/nextCPC,调用 setPage 加载内容;
第三种是翻到了preCPC/nextCPC,预览页,此时调用 loadPreviousChapter 或 loadNextChapter 方法。这两个方法会将当前的数据源直接替换成新章节的数据源,并修改页码。
这三种情况其实说白了就是当翻页到第二页或倒数第二页时预加载临近章节的数据,并在翻到第一页或倒数第一页时正式替换数据。这样做来保证可以在页面之间无缝切换。
我将第一个方法,也就是 willTransitionTo 注释掉,发现也能正常阅读漫画,似乎说明只靠第二个方法也能正常加载漫画,可能第一个方法是双保险。
DataSource
DataSource 中,根据 pageViewControllers 数组中的 VC 决定每一次翻页的内容。
首先需要提供数据源,需要将自身的 datasource 设置为一个符合 UIPageViewControllerDataSource 协议的类,这个类中需要实现两个方法:
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController?
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController?
PageView 中每一页都是由一个 ViewController 组成的,而上述两个方法的作用就是根据当前的 ViewController 推测出前/后一个 ViewController。
方法内部完成了两件事,首先是根据设置的阅读方向决定前后的页面;其次是根据是否是双页阅读决定页面是 ReaderPageVC 还是 ReaderDoubleVC。
后记
Aidoku 的整体结构很清晰,这让代码阅读起来没有什么太大压力,只是量比较大。
结构拆解相对来说是最关键的,理解了结构之后才可能更清晰的对代码中数据的流通有一个直观的认知。而 AI 在决定分析的方向上起到了很大帮助。如在ReaderViewController
中,尝试决定下一步应该分析ReaderPagedViewController
还是ReaderWebtoonViewController
时,使用 AI 快速协助阅读并理解代码,可以避免在不必要的地方浪费时间。
这里强烈推荐月之暗面的 LLM ,可以在 KimiChat 试用。虽然逻辑能力还比不上 GPT-4(主观感受强于 3.5),但上下文长度上,市面上的大语言模型无出其右。
而在具体的代码逻辑中,也获得了不少的实用代码部分:可缩放的工具 View、单双页灵活切换逻辑、前后章节预加载、已加载资源复用,都是后续可以用到的。
接下来要做的,就是尝试在 SwiftUI 中,复刻这个漫画阅读控件了。
我尝试过直接使用 UIViewControllerRepresentable 调用 ReaderViewController, 不过 VC 的逻辑层和表现层耦合的很强,很难只把几个 VC 复制到自己的项目中。