` 包裹传入的 HTML 内容。
3. **JavaScript 分页逻辑**:计算总页数,提供翻页函数(上一页、下一页、跳转到指定页)。
当用户在设置中切换主题或调整字号时,我们会用新的设置重新生成 HTML 并加载到 WebView 中。CSS 的 `column-width` 属性是实现翻页模式的关键——它把内容分成等宽的列,每一列就是一"页"。
### 4.3 HTML 模板引擎完整代码
? 项目文件:`FoxReader/Utilities/HTMLTemplates.swift`
```swift
import Foundation
/// HTML 模板生成器
/// 将阅读内容包装为完整的 HTML 文档,注入 CSS 样式和 JavaScript 分页逻辑
/// 这是实现「统一阅读样式」的核心组件
struct HTMLTemplates {
// MARK: - 主模板
/// 将内容包装为完整 HTML 文档
/// - Parameters:
/// - content: HTML 格式的正文内容
/// - settings: 阅读设置
/// - Returns: 完整的 HTML 字符串
static func wrapContent(_ content: String, settings: ReaderSettings) -> String {
let theme = settings.theme
let paginationCSS = settings.pageMode == .paginated ? paginatedCSS : scrollCSS
return """
\(content)
"""
}
// MARK: - CSS 片段
/// 滚动模式 CSS
private static var scrollCSS: String {
"""
body {
overflow-y: auto;
overflow-x: hidden;
}
"""
}
/// 翻页模式 CSS(使用 CSS 多列布局)
private static var paginatedCSS: String {
"""
html, body {
height: 100vh;
overflow: hidden;
}
.content {
column-width: 100vw;
column-gap: 0;
height: calc(100vh - 40px);
padding: 20px 16px;
}
"""
}
// MARK: - 辅助方法
/// 根据主题获取强调色
private static func accentColor(for theme: ReaderTheme) -> String {
switch theme {
case .light: return "#007AFF"
case .sepia: return "#8B6914"
case .dark: return "#5AC8FA"
case .night: return "#4A90A4"
}
}
/// 根据主题获取代码块背景色
private static func codeBgColor(for theme: ReaderTheme) -> String {
switch theme {
case .light: return "#F5F5F5"
case .sepia: return "#E8D9B0"
case .dark: return "#3A3A3A"
case .night: return "#2A2A2A"
}
}
/// 根据主题获取边框色
private static func borderColor(for theme: ReaderTheme) -> String {
switch theme {
case .light: return "#E0E0E0"
case .sepia: return "#D4C4A0"
case .dark: return "#555555"
case .night: return "#3A3A3A"
}
}
}
```
几个关键点:
- **CSS 多列分页**:翻页模式的核心是 `column-width: 100vw`。它让 `.content` 容器把内容分成多个列,每列宽度正好是一个屏幕宽度(`100vw`)。这样内容会自动横向溢出,用户左右滑动就是在"翻页"。配合 JavaScript 的 `window.scrollTo(page * clientWidth, 0)`,就能精确跳转到某一页。而滚动模式则简单地用 `overflow-y: auto` 让内容纵向滚动。
- **设置动态注入**:注意 CSS 中大量使用了 Swift 的字符串插值 `\(...)`。`background-color: \(theme.cssBackgroundColor)` 会把当前主题的背景色直接写入 CSS。`font-size: \(Int(settings.fontSize))px` 把用户选择的字号注入。这意味着每次设置改变,我们只需要重新调用 `wrapContent()` 生成新 HTML,WebView 重新加载后样式就会立刻更新。
- **主题感知的辅助色**:`accentColor(for:)`、`codeBgColor(for:)`、`borderColor(for:)` 这三个辅助方法根据当前主题返回不同的颜色值。比如白天模式的链接是蓝色(`#007AFF`),深色模式则是亮蓝色(`#5AC8FA`),确保在暗色背景下依然清晰可见。这种"同一套模板、不同主题色"的设计让每种主题都有精心调配的配色方案,而不是简单地反转颜色。
---
---
## 第五章:WKWebView 封装与统一阅读器
在前几章中,我们搭建好了数据模型和设置系统。现在是时候把内容真正展示出来了。FoxReader 支持两种主要格式:EPUB 和 Markdown。它们最终都会被转换成 HTML,然后渲染到屏幕上。那么问题来了——SwiftUI 怎么显示 HTML?
### 为什么要封装 WKWebView
SwiftUI 本身没有原生的网页视图组件。在 iOS 18 之前,如果你想显示一段 HTML 内容,唯一的办法就是借助 UIKit 里久经考验的 `WKWebView`。这就像你的新房子(SwiftUI)还没有装好窗户,但你手头有一扇旧房子(UIKit)拆下来的好窗户(WKWebView),直接装上就能用。
值得一提的是,WWDC25 上苹果终于为 SwiftUI 原生引入了 `WebView` 组件。这意味着未来我们可以像用 `Text`、`Image` 一样简单地嵌入网页视图。但作为学习项目,理解 `UIViewRepresentable` 这套桥接机制依然非常重要——它不仅是 WebView,所有 UIKit 组件迁移到 SwiftUI 都靠这个模式。
### UIViewRepresentable:UIKit 与 SwiftUI 的翻译官
`UIViewRepresentable` 是一座桥梁。想象一个场景:一位只会说中文的工程师(SwiftUI)和一位只会说英文的工人(UIKit)要合作。`UIViewRepresentable` 就是中间的翻译官,它定义了两个核心任务:
- `makeUIView`:告诉 UIKit 工人"创建一个什么东西"
- `updateUIView`:当 SwiftUI 的状态变化时,告诉 UIKit 工人"更新成什么样子"
还有第三个角色叫 `Coordinator`,它像工人的助手,专门负责监听 UIKit 那边发生的事件(比如页面加载完成),然后汇报给 SwiftUI。
? 项目文件:`FoxReader/Views/Reader/WebViewContainer.swift`
```swift
import SwiftUI
import WebKit
import Combine
// MARK: - WebView 状态管理器
/// 持有 WKWebView 的弱引用,提供 JavaScript 调用接口
/// 用于在 SwiftUI 视图中控制 WebView 的行为(如翻页)
final class WebViewState: ObservableObject {
weak var webView: WKWebView?
/// 执行 JavaScript 脚本
func evaluate(_ script: String) {
webView?.evaluateJavaScript(script)
}
/// 下一页(翻页模式)
func nextPage() {
evaluate("nextPage()")
}
/// 上一页(翻页模式)
func previousPage() {
evaluate("previousPage()")
}
}
// MARK: - UIColor Hex 扩展
private extension UIColor {
convenience init(hexString: String) {
let hex = hexString.replacingOccurrences(of: "#", with: "")
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
self.init(
red: CGFloat((int >> 16) & 0xFF) / 255,
green: CGFloat((int >> 8) & 0xFF) / 255,
blue: CGFloat(int & 0xFF) / 255,
alpha: 1.0
)
}
}
// MARK: - WebView 容器
/// WKWebView 的 SwiftUI 封装
/// 负责加载 HTML 内容、管理背景色、处理滚动
/// 当 htmlContent 变化时自动重新加载(如切换主题/字体后)
struct WebViewContainer: UIViewRepresentable {
let htmlContent: String
@ObservedObject var state: WebViewState
/// 主题背景色(CSS 格式,如 "#FFFFFF")
let backgroundColor: String
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.preferences.javaScriptEnabled = true
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.scrollView.delegate = context.coordinator
// 设置透明背景,由 CSS 控制实际背景色
webView.isOpaque = false
let bgColor = UIColor(hexString: backgroundColor)
webView.backgroundColor = bgColor
webView.scrollView.backgroundColor = bgColor
// 禁用 WebView 的默认弹性效果
webView.scrollView.bounces = false
// 保存引用
state.webView = webView
// 加载初始内容
webView.loadHTMLString(htmlContent, baseURL: nil)
context.coordinator.lastHTML = htmlContent
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
// 更新背景色
let bgColor = UIColor(hexString: backgroundColor)
webView.backgroundColor = bgColor
webView.scrollView.backgroundColor = bgColor
// 只在内容变化时重新加载(避免不必要的刷新)
if htmlContent != context.coordinator.lastHTML {
webView.loadHTMLString(htmlContent, baseURL: nil)
context.coordinator.lastHTML = htmlContent
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
// MARK: - Coordinator
/// WKWebView 的代理对象
/// 管理 HTML 内容的跟踪和页面加载完成后的回调
final class Coordinator: NSObject, WKNavigationDelegate, UIScrollViewDelegate {
/// 记录最后加载的 HTML,用于判断是否需要重新加载
var lastHTML: String = ""
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 页面加载完成后更新分页信息
webView.evaluateJavaScript("updatePageCount()")
}
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 可在此处处理滚动进度(未来扩展用)
}
}
}
```
上面这段代码有三个要点值得你注意:
第一,`WebViewState` 用 `weak` 弱引用持有 `WKWebView`。这是为了避免循环引用——SwiftUI 的视图持有 State,State 又持有 WebView,如果不用弱引用,内存就永远释放不了。
第二,`updateUIView` 里有一个关键的判断:`if htmlContent != context.coordinator.lastHTML`。SwiftUI 的特点是状态一变就会重新调用 `updateUIView`,但我们不想每次都重新加载整个网页(那样会闪一下)。所以我们用 `Coordinator` 记住上次加载的内容,只有真正变化时才重新加载。
第三,`Coordinator` 实现了 `WKNavigationDelegate`,在页面加载完成后立刻执行 JavaScript 函数 `updatePageCount()`。这是翻页模式的核心——JavaScript 负责计算总页数,Swift 负责触发翻页命令。
### ReaderViewModel:阅读器的大脑
有了渲染引擎,还需要一个"大脑"来管理状态:当前读的是哪本书?第几章?加载到哪了?这些逻辑都由 `ReaderViewModel` 统一管理。它被标记为 `@MainActor`,因为所有 UI 状态更新都必须在主线程完成。
? 项目文件:`FoxReader/ViewModels/ReaderViewModel.swift`
```swift
import Foundation
import SwiftUI
import Combine
/// 阅读器 ViewModel
/// 管理阅读状态:加载内容、章节导航、阅读进度
/// 支持 EPUB、Markdown、RSS 三种内容来源
@MainActor
final class ReaderViewModel: ObservableObject {
/// 当前显示的原始 HTML 内容(未经 HTMLTemplates 包装)
@Published var rawContent: String = ""
@Published var title: String = ""
@Published var chapters: [EPUBChapter] = []
@Published var currentChapter: Int = 0
@Published var totalChapters: Int = 0
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var readProgress: Double = 0
private var currentBook: Book?
// MARK: - 加载内容
/// 加载图书
/// 根据图书类型调用不同的解析逻辑
func loadBook(_ book: Book) {
currentBook = book
title = book.title
currentChapter = book.currentChapterIndex
errorMessage = nil
switch book.bookType {
case .epub:
Task { await loadEPUB(book) }
case .markdown:
loadMarkdown(book)
case .rss:
break
}
}
/// 加载 RSS 文章
func loadArticle(_ article: RSSArticle) {
currentBook = nil
title = article.title
rawContent = article.displayContent
totalChapters = 0
currentChapter = 0
}
// MARK: - EPUB 加载
private func loadEPUB(_ book: Book) async {
isLoading = true
let url = URL(fileURLWithPath: book.fileURL)
// 在后台线程解析 EPUB,避免阻塞 UI
let epubBook = await Task.detached(priority: .userInitiated) {
await EPUBService.parseEPUB(at: url)
}.value
isLoading = false
if let epubBook = epubBook {
chapters = epubBook.chapters
totalChapters = epubBook.chapters.count
// 加载当前章节内容
if currentChapter < chapters.count {
rawContent = chapters[currentChapter].htmlContent
} else if !chapters.isEmpty {
currentChapter = 0
rawContent = chapters[0].htmlContent
}
} else {
errorMessage = "无法解析 EPUB 文件,请确保已通过 SPM 添加 ZIPFoundation 依赖"
}
}
// MARK: - Markdown 加载
private func loadMarkdown(_ book: Book) {
let url = URL(fileURLWithPath: book.fileURL)
do {
let content = try String(contentsOf: url, encoding: .utf8)
rawContent = MarkdownService.convertToHTML(content)
totalChapters = 1
} catch {
errorMessage = "无法读取文件:\(error.localizedDescription)"
}
}
// MARK: - 章节导航
/// 加载指定章节
func loadChapter(at index: Int) {
guard index >= 0 && index < chapters.count else { return }
currentChapter = index
rawContent = chapters[index].htmlContent
updateProgress()
}
/// 下一章
func nextChapter() {
guard currentChapter < chapters.count - 1 else { return }
loadChapter(at: currentChapter + 1)
}
/// 上一章
func previousChapter() {
guard currentChapter > 0 else { return }
loadChapter(at: currentChapter - 1)
}
/// 是否有下一章
var hasNextChapter: Bool {
currentChapter < chapters.count - 1
}
/// 是否有上一章
var hasPreviousChapter: Bool {
currentChapter > 0
}
// MARK: - 阅读进度
/// 更新阅读进度
private func updateProgress() {
guard totalChapters > 0 else {
readProgress = 0
return
}
readProgress = Double(currentChapter + 1) / Double(totalChapters)
}
/// 保存阅读进度到 Book 对象
func saveProgress(to book: Book) {
book.lastReadDate = Date()
book.currentChapterIndex = currentChapter
book.readProgress = readProgress
}
}
```
注意 `loadEPUB` 方法中用到了 `Task.detached`。EPUB 解压和 XML 解析是 CPU 密集型操作,如果在主线程执行会卡住 UI。`Task.detached` 把它扔到后台线程,解析完再回到主线程更新 `@Published` 属性。
而 `saveProgress` 方法则体现了 SwiftData 的便利——直接修改 `Book` 对象的属性即可,不用写 SQL,不用手动持久化,上下文会处理一切。
### ReaderView:把一切组合在一起
现在我们有了渲染引擎(WebViewContainer)和大脑(ReaderViewModel),还需要一个"身体"把它们组合起来。`ReaderView` 就是这个身体,它同时支持 `Book`(本地图书)和 `RSSArticle`(网络文章)两种内容来源。
? 项目文件:`FoxReader/Views/Reader/ReaderView.swift`
```swift
import SwiftUI
import SwiftData
/// 统一阅读器视图
/// 支持显示 Book(EPUB/Markdown)和 RSSArticle 内容
/// 通过 WebViewContainer 渲染 HTML 内容,通过 HTMLTemplates 应用阅读样式
struct ReaderView: View {
var book: Book? = nil
var article: RSSArticle? = nil
@EnvironmentObject var readerSettings: ReaderSettings
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel = ReaderViewModel()
@StateObject private var webViewState = WebViewState()
@State private var showSettings = false
var body: some View {
ZStack {
// 主题背景色
readerSettings.theme.backgroundColor
.ignoresSafeArea()
// 主内容区
VStack(spacing: 0) {
contentArea
// EPUB 章节导航栏
if viewModel.totalChapters > 1 {
chapterNavigationBar
}
}
}
.navigationTitle(viewModel.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
// 章节列表(仅 EPUB)
if viewModel.totalChapters > 1 {
ToolbarItem(placement: .topBarLeading) {
Menu {
ForEach(0..
(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.headline)
HStack {
Spacer()
content()
Spacer()
}
}
}
// MARK: - 主题按钮
private func themeButton(_ theme: ReaderTheme) -> some View {
let isSelected = readerSettings.theme == theme
return Button {
readerSettings.theme = theme
} label: {
VStack(spacing: 6) {
Circle()
.fill(theme.backgroundColor)
.frame(width: 44, height: 44)
.overlay {
Circle()
.stroke(
isSelected ? Color.accentColor : Color.gray.opacity(0.3),
lineWidth: isSelected ? 3 : 1
)
}
.overlay {
Image(systemName: theme.iconName)
.font(.caption)
.foregroundStyle(theme.textColor)
}
Text(theme.displayName)
.font(.caption)
.foregroundStyle(isSelected ? Color.accentColor : .primary)
}
}
}
// MARK: - 字体按钮
private func fontButton(_ font: ReaderFont) -> some View {
let isSelected = readerSettings.fontFamily == font
return Button {
readerSettings.fontFamily = font
} label: {
HStack(spacing: 6) {
Image(systemName: font.iconName)
Text(font.displayName)
}
.font(.subheadline)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background {
if isSelected {
Capsule().fill(Color.accentColor.opacity(0.15))
} else {
Capsule().fill(Color.gray.opacity(0.1))
}
}
.foregroundStyle(isSelected ? Color.accentColor : .primary)
}
}
// MARK: - 翻页模式按钮
private func pageModeButton(_ mode: PageMode) -> some View {
let isSelected = readerSettings.pageMode == mode
return Button {
readerSettings.pageMode = mode
} label: {
HStack(spacing: 6) {
Image(systemName: mode.iconName)
Text(mode.displayName)
}
.font(.subheadline)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background {
if isSelected {
Capsule().fill(Color.accentColor.opacity(0.15))
} else {
Capsule().fill(Color.gray.opacity(0.1))
}
}
.foregroundStyle(isSelected ? Color.accentColor : .primary)
}
}
// MARK: - 字体大小调整
private func increaseFontSize() {
readerSettings.fontSize = min(readerSettings.fontSize + 1, 28)
}
private func decreaseFontSize() {
readerSettings.fontSize = max(readerSettings.fontSize - 1, 12)
}
}
```
这个面板有两个设计亮点:
第一是 `settingsSection` 泛型辅助方法。它接受一个标题字符串和一个 `@ViewBuilder` 闭包,统一了每个设置区块的布局(标题在左,内容居中)。这种模式在 SwiftUI 中非常常见——把重复的布局逻辑提取成一个方法,既减少代码量又保证视觉一致性。
第二是 `.presentationDetents([.medium, .large])`。这让 Sheet 可以停留在半屏或全屏两个位置,用户可以向上拖动展开更多内容。配合 `.presentationDragIndicator(.visible)`,顶部会显示一个小横条作为拖动手柄的视觉提示。
### 临时占位代码(确保项目可编译)
本章的 `ReaderViewModel` 和 `ReaderView` 引用了几个后续章节才定义的类型:`EPUBChapter`、`EPUBBook`、`EPUBService`(第八章)、`MarkdownService`(第七章)、`RSSArticle`(第九章)。为了让你在本章结束时就能编译运行阅读器,请将以下占位代码**追加到** `Stubs.swift` 文件中:
> ? 项目文件:`FoxReader/Stubs.swift`(追加以下代码)
```swift
// ─── 第七章将替换 ───
struct MarkdownService {
/// 简单占位:直接返回段落标签包裹的文本
static func convertToHTML(_ markdown: String) -> String {
"\(markdown)
"
}
}
// ─── 第八章将替换 ───
struct EPUBChapter {
let htmlContent: String
}
struct EPUBBook {
let title: String
let author: String
let coverImageData: Data?
let chapters: [EPUBChapter]
}
struct EPUBService {
/// 占位:返回 nil,第八章实现完整解析
static func parseEPUB(at url: URL) -> EPUBBook? {
nil
}
}
// ─── 第九章将替换 ───
struct RSSArticle: Identifiable, Hashable {
let id: UUID
let title: String
let link: String
let summary: String
let content: String
let publishDate: Date?
let author: String?
/// 获取用于显示的 HTML 内容
var displayContent: String {
content.isEmpty ? summary : content
}
init(id: UUID = UUID(), title: String = "", link: String = "",
summary: String = "", content: String = "",
publishDate: Date? = nil, author: String? = nil) {
self.id = id
self.title = title
self.link = link
self.summary = summary
self.content = content
self.publishDate = publishDate
self.author = author
}
}
```
> **提示**:这些占位类型提供了阅读器编译所需的最小接口。`MarkdownService` 会简单地把文本包裹在 `` 标签中,`EPUBService.parseEPUB` 会返回 `nil`(即 EPUB 解析不可用),`RSSArticle` 提供了 `displayContent` 计算属性。在后续章节中,这些占位将被完整实现替换。
现在编译项目(**Cmd+B**),应该能成功通过。你可以尝试导入一个 Markdown 文件,阅读器会以基础格式显示其内容。
---
## 第六章:书架与图书管理
> **替换占位代码**:在开始本章之前,请打开 `Stubs.swift` 文件,删除 `LibraryView` 占位类型。本章将创建它的完整实现。注意:`EPUBService` 和 `EPUBBook` 仍为占位状态(在第五章添加),将在第八章替换。
每本好书都需要一个好书架。在 FoxReader 中,书架是用户打开 App 后看到的第一个界面——就像你走进家里的书房,一眼看到整齐排列的书本。这一章我们来看看书架是如何构建的,以及图书是如何从用户的文件系统"搬"到 App 里的。
### @Query:SwiftData 的智能搜索
SwiftData 提供了一个非常强大的宏 `@Query`。它就像一个不知疲倦的图书管理员——你告诉他"按添加日期倒序排列所有书",他就会一直守在那里,每当书架有变化(添加新书、删除旧书),他都会自动刷新列表,你完全不用手动管理。
? 项目文件:`FoxReader/Views/Library/LibraryView.swift`
```swift
import SwiftUI
import SwiftData
struct LibraryView: View {
@Query(sort: \Book.addedDate, order: .reverse) var books: [Book]
@Environment(\.modelContext) var modelContext
@EnvironmentObject var readerSettings: ReaderSettings
@StateObject private var viewModel = LibraryViewModel()
@State private var showImportPicker = false
var body: some View {
NavigationStack {
Group {
if books.isEmpty {
EmptyStateView(
icon: "books.vertical",
title: "书架空空如也",
subtitle: "点击右上角导入按钮添加图书",
actionTitle: "导入图书",
action: { showImportPicker = true }
)
} else {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: 20) {
ForEach(books) { book in
NavigationLink(value: book) {
BookCardView(book: book)
}
.contextMenu {
Button("删除", role: .destructive) {
viewModel.deleteBook(book, context: modelContext)
}
}
}
}
.padding()
}
}
}
.navigationTitle("书架")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showImportPicker = true } label: {
Image(systemName: "plus")
}
}
}
.navigationDestination(for: Book.self) { book in
ReaderView(book: book)
.environmentObject(readerSettings)
}
.sheet(isPresented: $showImportPicker) {
DocumentPickerView { url in
viewModel.importBook(at: url, context: modelContext)
}
}
.alert("提示", isPresented: $viewModel.showError) {
Button("确定", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
private var gridColumns: [GridItem] {
[GridItem(.adaptive(minimum: 160), spacing: 16)]
}
}
```
注意 `@Query(sort: \Book.addedDate, order: .reverse)` 这一行。`@Query` 会自动监听数据库变化——当你通过 `modelContext.insert()` 添加新书后,`books` 数组会自动更新,UI 也会自动刷新。这比传统的 Core Data + NSFetchedResultsController 简洁太多了。
另一个细节是 `NavigationLink(value: book)` 配合 `.navigationDestination(for: Book.self)`。这是 SwiftUI 4.0 引入的类型安全导航方式——你把 `Book` 作为导航值传递,SwiftUI 会根据类型自动找到对应的目标视图,避免了旧版 `NavigationLink(destination:)` 那种紧耦合的写法。
### BookCardView:书的封面
每本书在书架上以卡片形式展示。如果有封面图片就显示图片,没有则用渐变背景和图标代替。
? 项目文件:`FoxReader/Views/Library/BookCardView.swift`
```swift
import SwiftUI
import UIKit
struct BookCardView: View {
let book: Book
var body: some View {
VStack(alignment: .leading, spacing: 8) {
coverView
.frame(width: 160, height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.15), radius: 5, y: 3)
Text(book.title)
.font(.subheadline)
.lineLimit(2)
.foregroundStyle(.primary)
Text(book.author)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if book.readProgress > 0 {
ProgressView(value: book.readProgress)
.tint(.accentColor)
}
}
.frame(width: 160)
}
@ViewBuilder
private var coverView: some View {
if let coverData = book.coverImageData,
let uiImage = UIImage(data: coverData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ZStack {
LinearGradient(
colors: [Color(hex: "667eea"), Color(hex: "764ba2")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
VStack(spacing: 8) {
Image(systemName: book.bookType.iconName)
.font(.system(size: 32))
.foregroundStyle(.white.opacity(0.9))
Text(book.bookType.displayName)
.font(.caption)
.foregroundStyle(.white.opacity(0.7))
Text(book.title.prefix(6))
.font(.caption2)
.foregroundStyle(.white.opacity(0.5))
.lineLimit(1)
}
}
}
}
}
```
封面卡片底部有一个阅读进度条 `ProgressView(value: book.readProgress)`。只有当 `readProgress > 0` 时才显示,这样还没读过的书不会显示一个空的进度条。这是个很小的细节,但对用户体验很重要——清晰的视觉反馈远比一堆空状态元素要舒服。
### DocumentPickerView:文件选择器
SwiftUI 同样没有原生的文件选择器。我们需要用 `UIViewControllerRepresentable` 来桥接 UIKit 的 `UIDocumentPickerViewController`。这个模式和上一章的 `UIViewRepresentable` 几乎一样,只是把 `UIView` 换成了 `UIViewController`。
? 项目文件:`FoxReader/Views/Components/DocumentPickerView.swift`
```swift
import SwiftUI
import UniformTypeIdentifiers
struct DocumentPickerView: UIViewControllerRepresentable {
var onPick: (URL) -> Void
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let epubType = UTType(importedAs: "org.idpf.epub-container")
let markdownType = UTType(importedAs: "net.daringfireball.markdown")
let types: [UTType] = [epubType, markdownType, .plainText, .text]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types)
picker.allowsMultipleSelection = false
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: DocumentPickerView
init(_ parent: DocumentPickerView) {
self.parent = parent
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
if let url = urls.first {
parent.onPick(url)
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {}
}
}
```
这里用到了 `UTType`(Uniform Type Identifiers)来指定可选择的文件类型。EPUB 的类型标识符是 `org.idpf.epub-container`,这是国际数字出版论坛(IDPF)定义的标准。`plainText` 和 `text` 则覆盖了 `.txt` 和 `.md` 文件。
`Coordinator` 持有一个对 `parent`(即 SwiftUI 视图本身)的引用,当用户选好文件后,通过闭包 `onPick(url)` 把结果传回 SwiftUI 世界。
### EmptyStateView:空状态提示
当书架还没有任何书时,一个友好的空状态视图比一片空白要好得多。
? 项目文件:`FoxReader/Views/Components/EmptyStateView.swift`
```swift
import SwiftUI
struct EmptyStateView: View {
let icon: String
let title: String
let subtitle: String
var actionTitle: String? = nil
var action: (() -> Void)? = nil
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 56))
.foregroundStyle(.secondary.opacity(0.4))
Text(title)
.font(.title3)
.foregroundStyle(.secondary)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
if let actionTitle = actionTitle, let action = action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.padding(.top, 8)
}
}
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
```
这个组件是通用的——`icon`、`title`、`subtitle` 都是外部传入的参数,`actionTitle` 和 `action` 是可选的。这意味着你可以在 App 的任何地方复用它:书架为空、搜索无结果、网络错误等场景,都能用同一个组件展示不同的内容。
### LibraryViewModel:书架的业务逻辑
书架的 UI 是 `LibraryView`,但导入和删除的具体逻辑被抽到了 `LibraryViewModel` 里。这种分离让代码更清晰,也让 ViewModel 更容易测试。
? 项目文件:`FoxReader/ViewModels/LibraryViewModel.swift`
```swift
import Foundation
import SwiftData
import SwiftUI
import Combine
/// 书架 ViewModel
/// 管理图书导入、删除等业务逻辑
/// 通过 ModelContext 参数与 SwiftData 交互,保持 ViewModel 的可测试性
@MainActor
final class LibraryViewModel: ObservableObject {
@Published var showImportPicker = false
@Published var errorMessage: String?
@Published var showError = false
/// 导入图书文件
/// - Parameters:
/// - url: 用户选择的文件 URL
/// - context: SwiftData ModelContext(由视图注入)
func importBook(at url: URL, context: ModelContext) {
if let book = BookImportService.importFile(at: url) {
context.insert(book)
do {
try context.save()
} catch {
showError(message: "保存失败:\(error.localizedDescription)")
}
} else {
showError(message: "不支持的文件格式,仅支持 EPUB 和 Markdown 文件")
}
}
/// 删除图书
/// - Parameters:
/// - book: 要删除的 Book 对象
/// - context: SwiftData ModelContext
func deleteBook(_ book: Book, context: ModelContext) {
// 同时删除本地文件
let fileURL = URL(fileURLWithPath: book.fileURL)
try? FileManager.default.removeItem(at: fileURL)
context.delete(book)
do {
try context.save()
} catch {
showError(message: "删除失败:\(error.localizedDescription)")
}
}
private func showError(message: String) {
errorMessage = message
showError = true
}
}
```
注意 `importBook` 和 `deleteBook` 方法都接收 `ModelContext` 作为参数,而不是在 ViewModel 内部获取。这是一种依赖注入的设计——ViewModel 不关心数据从哪来,它只负责处理逻辑。这样在写单元测试时,你可以传入一个内存数据库的 context 来测试,完全不用碰真实文件系统。
### BookImportService:文件搬运工
当用户选择了一个文件后,这个文件并不属于 App——它在 iOS 的安全沙盒之外。`BookImportService` 的职责就是把文件"搬"进 App 自己的 Documents 目录,然后创建对应的 `Book` 对象。
? 项目文件:`FoxReader/Services/BookImportService.swift`
```swift
import Foundation
/// 图书导入服务
/// 协调不同格式的图书导入流程,将文件复制到 App 沙盒目录并创建 Book 对象
struct BookImportService {
/// 导入文件,根据扩展名自动判断类型
/// - Parameter sourceURL: 用户选择的文件 URL(通常来自 Document Picker)
/// - Returns: 创建好的 Book 对象,如果格式不支持则返回 nil
static func importFile(at sourceURL: URL) -> Book? {
let ext = sourceURL.pathExtension.lowercased()
switch ext {
case "epub":
return importEPUB(at: sourceURL)
case "md", "markdown", "txt":
return importMarkdown(at: sourceURL)
default:
return nil
}
}
/// 导入 EPUB 文件
private static func importEPUB(at sourceURL: URL) -> Book? {
// 复制文件到沙盒
guard let destURL = copyToDocuments(from: sourceURL) else { return nil }
// 解析 EPUB 获取元数据
if let epubBook = EPUBService.parseEPUB(at: destURL) {
return Book(
title: epubBook.title,
author: epubBook.author,
bookType: .epub,
fileURL: destURL.path,
coverImageData: epubBook.coverImageData,
totalChapters: epubBook.chapters.count
)
}
// 解析失败时仍然创建 Book(可能未安装 ZIPFoundation)
let title = sourceURL.deletingPathExtension().lastPathComponent
return Book(
title: title,
bookType: .epub,
fileURL: destURL.path
)
}
/// 导入 Markdown 文件
private static func importMarkdown(at sourceURL: URL) -> Book? {
guard let destURL = copyToDocuments(from: sourceURL) else { return nil }
let title = sourceURL.deletingPathExtension().lastPathComponent
return Book(
title: title,
bookType: .markdown,
fileURL: destURL.path,
totalChapters: 1
)
}
/// 将文件复制到 App 的 Documents 目录
/// 返回复制后的目标 URL,如果失败返回 nil
private static func copyToDocuments(from sourceURL: URL) -> URL? {
let fileManager = FileManager.default
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
// 生成唯一文件名避免冲突
let fileName = "\(UUID().uuidString)_\(sourceURL.lastPathComponent)"
let destURL = documentsDir.appendingPathComponent(fileName)
// 访问安全域资源(iOS Document Picker 返回的 URL 需要安全域访问权限)
let didAccess = sourceURL.startAccessingSecurityScopedResource()
defer {
if didAccess {
sourceURL.stopAccessingSecurityScopedResource()
}
}
// 如果目标文件已存在则先删除
if fileManager.fileExists(atPath: destURL.path) {
try? fileManager.removeItem(at: destURL)
}
do {
try fileManager.copyItem(at: sourceURL, to: destURL)
return destURL
} catch {
return nil
}
}
/// 获取 App Documents 目录路径
static var documentsDirectory: URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
}
}
```
这段代码里有一个非常关键的概念:**安全域资源访问**(Security-Scoped Resource)。iOS 的文件系统是严格沙盒化的,用户通过 Document Picker 选择的文件返回的 URL 是一个"安全域" URL——你必须调用 `startAccessingSecurityScopedResource()` 才能读取它,用完之后必须调用 `stopAccessingSecurityScopedResource()` 释放权限。代码中用 `defer` 确保无论如何都会释放,这是一个好习惯。
另一个要点是文件名的生成:`\(UUID().uuidString)_\(sourceURL.lastPathComponent)`。UUID 前缀确保即使导入两个同名文件也不会冲突。文件复制到 Documents 目录后,App 就拥有了完全的读写权限,后续读取不再需要安全域访问。
---
## 第七章:Markdown 解析与阅读
> **替换占位代码**:在开始本章之前,请打开 `Stubs.swift` 文件,删除 `MarkdownService` 占位类型。本章将创建它的完整实现。
Markdown 是一种轻量级标记语言,用简单的符号就能写出格式丰富的文本。比如 `**粗体**` 变成粗体,`# 标题` 变成一级标题。它在技术文档、博客、笔记领域非常流行。FoxReader 支持直接导入 `.md` 文件并渲染成漂亮的阅读界面。
### 自己动手写解析器
市面上有很多成熟的 Markdown 解析库,但我们选择自己实现一个。这不仅仅是为了减少依赖,更重要的是——理解一个解析器是怎么工作的,本身就是非常好的编程练习。
打个比方,这就像教一个人把英文翻译成中文。你不需要翻译每一句话,你只需要告诉它规则:看到 "Hello" 就翻译成"你好",看到 "Thank you" 就翻译成"谢谢"。Markdown 解析器也是一样的思路:看到 `#` 就变成 `
`,看到 `**` 就变成 ``。
我们的解析策略分两个层次:
- **块级元素**:逐行扫描,识别标题、列表、引用、代码块等。这些元素独占一行或多行。
- **行内元素**:在一行文字内部,用正则表达式替换粗体、斜体、链接、图片等。
? 项目文件:`FoxReader/Services/MarkdownService.swift`
```swift
import Foundation
/// Markdown 转 HTML 转换器
/// 实现一个轻量级的 Markdown 解析器,支持常用语法
/// 无需第三方依赖,纯 Foundation 实现
struct MarkdownService {
// MARK: - 公开方法
/// 将 Markdown 文本转换为 HTML
/// - Parameter markdown: Markdown 原始文本
/// - Returns: HTML 字符串(不含 html/head/body 标签,仅正文)
static func convertToHTML(_ markdown: String) -> String {
var result = ""
var inCodeBlock = false
var codeBlockContent = ""
var inUnorderedList = false
var inOrderedList = false
var inBlockquote = false
var paragraphBuffer = ""
let lines = markdown.components(separatedBy: .newlines)
for line in lines {
// 代码块处理
if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
if inCodeBlock {
// 结束代码块
result += "\(escapeHTML(codeBlockContent.trimmingCharacters(in: .newlines)))
\n"
codeBlockContent = ""
inCodeBlock = false
} else {
// 开始代码块 - 先关闭其他元素
flushParagraph(¶graphBuffer, &result)
closeLists(&inUnorderedList, &inOrderedList, &result)
closeBlockquote(&inBlockquote, &result)
inCodeBlock = true
}
continue
}
if inCodeBlock {
codeBlockContent += line + "\n"
continue
}
let trimmed = line.trimmingCharacters(in: .whitespaces)
// 空行 - 段落分隔
if trimmed.isEmpty {
flushParagraph(¶graphBuffer, &result)
closeLists(&inUnorderedList, &inOrderedList, &result)
closeBlockquote(&inBlockquote, &result)
continue
}
// 标题处理
if let headingHTML = parseHeading(trimmed) {
flushParagraph(¶graphBuffer, &result)
closeLists(&inUnorderedList, &inOrderedList, &result)
closeBlockquote(&inBlockquote, &result)
result += headingHTML + "\n"
continue
}
// 水平分割线
if isHorizontalRule(trimmed) {
flushParagraph(¶graphBuffer, &result)
closeLists(&inUnorderedList, &inOrderedList, &result)
closeBlockquote(&inBlockquote, &result)
result += "
\n"
continue
}
// 引用块
if trimmed.hasPrefix(">") {
flushParagraph(¶graphBuffer, &result)
closeLists(&inUnorderedList, &inOrderedList, &result)
if !inBlockquote {
result += "\n"
inBlockquote = true
}
let quoteContent = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
result += "\(processInline(quoteContent))
\n"
continue
} else {
closeBlockquote(&inBlockquote, &result)
}
// 无序列表
if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
flushParagraph(¶graphBuffer, &result)
closeOrderedList(&inOrderedList, &result)
if !inUnorderedList {
result += "