S SmartDocs
Série: iOS App latex 1070 lignes · Mis à jour 2026-07-15

tutorial.tex

iOS_App/TodoList-Storyboard/docs/tutorial.tex

% !TEX program = xelatex
% 編譯方式:xelatex -shell-escape tutorial.tex(需執行兩次以產生目錄)
\documentclass[12pt,a4paper]{report}

% ---------- 字型與中文設定 ----------
\usepackage{fontspec}
\usepackage{xeCJK}
\setCJKmainfont[BoldFont={Heiti TC},ItalicFont={Songti TC},ItalicFeatures={FakeSlant=0.2}]{Songti TC}
\setCJKsansfont[ItalicFont={Heiti TC},ItalicFeatures={FakeSlant=0.2}]{Heiti TC}
\setCJKmonofont{Heiti TC}
\setmainfont{Times New Roman}
\setsansfont{Helvetica}
\setmonofont{Menlo}
\XeTeXlinebreaklocale "zh"
\XeTeXlinebreakskip = 0pt plus 1pt

% ---------- 版面設定 ----------
\usepackage[a4paper,top=2.5cm,bottom=2.5cm,left=2.5cm,right=2.5cm,headheight=14pt]{geometry}
\usepackage{setspace}
\setstretch{1.35}
\setlength{\parindent}{2em}
\setlength{\parskip}{0.4em}
% 放寬斷行條件,避免行內程式碼造成文字超出邊界
\emergencystretch=3em
\tolerance=2000
\hbadness=10000

% ---------- 顏色與圖形 ----------
\usepackage{xcolor}
\definecolor{swiftorange}{RGB}{240,81,56}
\definecolor{codebg}{RGB}{248,248,250}
\definecolor{tipblue}{RGB}{0,102,204}
\definecolor{warnred}{RGB}{200,50,50}
\definecolor{linkblue}{RGB}{0,90,180}

% ---------- 程式碼(minted) ----------
\usepackage[outputdir=build]{minted}
\setminted{
  fontsize=\small,
  breaklines=true,
  linenos=true,
  numbersep=8pt,
  frame=lines,
  framesep=8pt,
  bgcolor=codebg,
  tabsize=4
}
\setmintedinline{fontsize=\normalsize,breaklines=true,breakanywhere=true}
% 行內程式碼快捷指令
\newcommand{\code}[1]{\mintinline{swift}|#1|}

% ---------- 提示框 ----------
\usepackage[most]{tcolorbox}
\newtcolorbox{tipbox}{
  colback=tipblue!6, colframe=tipblue!70, title=\sffamily 小提示,
  fonttitle=\bfseries, breakable, left=6pt, right=6pt
}
\newtcolorbox{warnbox}{
  colback=warnred!6, colframe=warnred!70, title=\sffamily 注意,
  fonttitle=\bfseries, breakable, left=6pt, right=6pt
}
\newtcolorbox{conceptbox}[1]{
  colback=swiftorange!5, colframe=swiftorange!80, title=\sffamily 觀念:#1,
  fonttitle=\bfseries, breakable, left=6pt, right=6pt
}

% ---------- 圖片與表格 ----------
\usepackage{graphicx}
\usepackage{float}
\usepackage{booktabs}
\usepackage{longtable}
\usepackage{array}
\usepackage{enumitem}
\setlist{itemsep=2pt,topsep=4pt}

% ---------- 章節標題樣式 ----------
\usepackage{titlesec}
\titleformat{\chapter}[display]
  {\sffamily\bfseries\Huge\color{swiftorange!85!black}}
  {\filleft 第\,\thechapter\,章}{12pt}{\filleft}
\titlespacing*{\chapter}{0pt}{-20pt}{30pt}
\titleformat{\section}{\sffamily\bfseries\Large\color{black}}{\thesection}{0.8em}{}
\titleformat{\subsection}{\sffamily\bfseries\large}{\thesubsection}{0.8em}{}

% ---------- 頁首頁尾 ----------
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small\sffamily iOS 待辦事項 App 開發教學}
\fancyhead[R]{\small\sffamily Xcode / Storyboard / Swift}
\fancyfoot[C]{\small\thepage}
\renewcommand{\headrulewidth}{0.4pt}

% ---------- 超連結 ----------
\usepackage[colorlinks=true,linkcolor=linkblue,urlcolor=linkblue,bookmarksnumbered=true]{hyperref}

% ---------- 中文化名稱 ----------
\renewcommand{\contentsname}{目錄}
\renewcommand{\figurename}{圖}
\renewcommand{\tablename}{表}

% ---------- 逐行解說環境 ----------
% \lineexp{行號}{說明}
\newcommand{\lineexp}[2]{\item[\textbf{\color{swiftorange!85!black}第 #1 行}] #2}
\newenvironment{linelist}
  {\begin{description}[leftmargin=2.2cm,style=nextline,itemsep=4pt]}
  {\end{description}}

\begin{document}

% ================= 封面 =================
\begin{titlepage}
\centering
\vspace*{2cm}
{\sffamily\bfseries\Huge 用 Xcode + Storyboard + Swift\\[0.5em] 打造你的第一個 iOS App\par}
\vspace{1.2cm}
{\sffamily\LARGE\color{swiftorange} 待辦事項清單(To-Do List)完整開發教學\par}
\vspace{2cm}
{\Large 從專案建立、介面設計、Core Data 資料儲存\\[0.3em] 到逐行程式解說與自動化測試\par}
\vspace{3cm}
\begin{tabular}{rl}
\textbf{開發工具} & Xcode 26 \\
\textbf{介面技術} & Storyboard(UIKit) \\
\textbf{程式語言} & Swift 5 \\
\textbf{資料儲存} & Core Data \\
\textbf{適用對象} & iOS 開發初學者 \\
\end{tabular}
\vfill
{\large 2026 年 7 月\par}
\end{titlepage}

% ================= 目錄 =================
\tableofcontents
\clearpage

% =========================================================
\chapter{課程簡介與成品預覽}
% =========================================================

\section{我們要做什麼?}

在這份教材中,我們會從零開始,用 \textbf{Xcode}、\textbf{Storyboard} 與 \textbf{Swift} 開發一個功能完整的「待辦事項清單(To-Do List)」App。這是 iOS 開發最經典的入門題目,因為它涵蓋了幾乎所有 App 開發的核心觀念:

\begin{itemize}
  \item \textbf{介面設計}:使用 Storyboard 拖拉元件、設定 Auto Layout 約束。
  \item \textbf{表格列表}:使用 \code{UITableView} 顯示動態資料。
  \item \textbf{使用者互動}:點擊按鈕、彈出對話框、滑動刪除/編輯。
  \item \textbf{資料永久儲存}:使用 Core Data 把資料存在手機裡,關掉 App 也不會消失。
  \item \textbf{MVC 架構}:理解 Model(資料)、View(畫面)、Controller(邏輯)的分工。
\end{itemize}

\section{完成後的 App 功能}

\begin{itemize}
  \item \textbf{新增}:點右上角「+」,輸入文字即可新增一筆待辦事項。
  \item \textbf{完成}:點擊某一筆事項,切換「已完成/未完成」;已完成的事項會加上刪除線、變灰、並顯示藍色勾勾。
  \item \textbf{編輯}:向右滑動某一筆事項,出現藍色「編輯」按鈕,可修改文字。
  \item \textbf{刪除}:向左滑動某一筆事項,出現紅色「刪除」按鈕。
  \item \textbf{永久儲存}:所有資料透過 Core Data 存放在裝置上,重開 App 資料仍在。
  \item \textbf{空狀態提示}:清單為空時,畫面中央顯示友善的提示文字。
\end{itemize}

\section{成品畫面}

\begin{figure}[H]
\centering
\includegraphics[height=9.5cm]{images/screenshot-empty.png}\hspace{1cm}
\includegraphics[height=9.5cm]{images/screenshot-list.png}
\caption{左:清單為空時的提示畫面。右:已加入兩筆事項,其中「買牛奶」已標記完成(刪除線+灰字+勾勾)。}
\end{figure}

\section{專案檔案總覽}

完成後的專案結構如下,本教材會逐一講解每個檔案的角色:

\begin{minted}[linenos=false]{text}
TodoList-Storyboard/
├── TodoList-Storyboard.xcodeproj        ← Xcode 專案檔
└── TodoList-Storyboard/
    ├── AppDelegate.swift                ← App 進入點與 Core Data 堆疊
    ├── SceneDelegate.swift              ← 視窗(Scene)生命週期
    ├── ViewController.swift             ← 主畫面邏輯(本教材重點)
    ├── Base.lproj/
    │   ├── Main.storyboard              ← 主畫面介面設計
    │   └── LaunchScreen.storyboard      ← 啟動畫面
    ├── TodoList_Storyboard.xcdatamodeld ← Core Data 資料模型
    ├── Assets.xcassets                  ← 圖片與顏色資源
    └── Info.plist                       ← App 設定檔
\end{minted}

% =========================================================
\chapter{開發環境與專案建立}
% =========================================================

\section{需要準備什麼?}

\begin{itemize}
  \item 一台 Mac 電腦(macOS)。
  \item \textbf{Xcode}:Apple 官方的整合開發環境(IDE),可從 Mac App Store 免費下載。本教材使用 Xcode 26。
  \item 不需要實體 iPhone——Xcode 內建「模擬器(Simulator)」即可執行與測試 App。
\end{itemize}

\section{建立新專案(Step by Step)}

\begin{enumerate}
  \item 打開 Xcode,選擇 \textbf{File → New → Project...}(或在歡迎視窗點「Create New Project...」)。
  \item 在範本選擇畫面,平台選 \textbf{iOS},範本選 \textbf{App},按 \textbf{Next}。
  \item 填寫專案資訊:
    \begin{itemize}
      \item \textbf{Product Name}:\texttt{TodoList-Storyboard}
      \item \textbf{Interface}:\textbf{Storyboard}(重點!不要選 SwiftUI)
      \item \textbf{Language}:\textbf{Swift}
      \item \textbf{Storage}:勾選 \textbf{Core Data}(Xcode 會自動幫我們產生 Core Data 的樣板程式碼)
      \item \textbf{Include Tests}:勾選(我們最後會寫 UI 測試)
    \end{itemize}
  \item 按 \textbf{Next},選擇專案儲存位置,按 \textbf{Create}。
\end{enumerate}

\begin{tipbox}
「Interface: Storyboard」代表用「視覺化拖拉」的方式設計畫面,適合初學者建立對 UIKit 的直觀理解;「SwiftUI」則是 Apple 較新的宣告式介面框架。兩者觀念相通,先學會其中一種,另一種會更容易上手。
\end{tipbox}

\section{認識 Xcode 介面}

打開專案後,Xcode 視窗分為幾大區域:

\begin{itemize}
  \item \textbf{左側導覽區(Navigator)}:顯示專案所有檔案,點檔案即可開啟。
  \item \textbf{中央編輯區(Editor)}:編輯程式碼,或以視覺化方式編輯 Storyboard。
  \item \textbf{右側檢查器(Inspector)}:選取 Storyboard 元件後,在這裡調整屬性(文字、顏色、字型等)。
  \item \textbf{上方工具列}:選擇模擬器機型、按 \textbf{Run(播放鍵)} 建置並執行 App(快捷鍵 \texttt{Cmd + R})。
  \item \textbf{下方除錯區(Debug Area)}:顯示 \code{print()} 輸出與錯誤訊息。
\end{itemize}

\section{Xcode 幫我們產生了哪些檔案?}

建立專案後,Xcode 已自動產生下列檔案,每個檔案的角色如下表:

\begin{table}[H]
\centering
\small
\begin{tabular}{p{5.6cm}p{8.9cm}}
\toprule
\textbf{檔案} & \textbf{角色} \\
\midrule
\texttt{AppDelegate.swift} & App 的「總管」:App 啟動、進入背景等系統事件都在這裡處理;勾選 Core Data 後,這裡也放 Core Data 的核心程式碼。 \\
\texttt{SceneDelegate.swift} & 管理「一個視窗(Scene)」的生命週期(前景/背景切換等)。 \\
\texttt{ViewController.swift} & 主畫面的控制器,我們大部分的程式都寫在這裡。 \\
\texttt{Main.storyboard} & 主畫面的視覺化設計檔。 \\
\texttt{LaunchScreen.storyboard} & App 啟動瞬間顯示的過場畫面。 \\
\texttt{TodoList\_Storyboard}\allowbreak\texttt{.xcdatamodeld} & Core Data 的「資料模型」設計檔。 \\
\texttt{Assets.xcassets} & 放 App 圖示、圖片、顏色等資源。 \\
\texttt{Info.plist} & App 的設定檔(例如支援的畫面方向)。 \\
\bottomrule
\end{tabular}
\caption{專案範本檔案一覽}
\end{table}

% =========================================================
\chapter{核心觀念:MVC 與 App 的運作流程}
% =========================================================

\section{MVC 架構}

UIKit App 採用 \textbf{MVC(Model-View-Controller)}架構,把程式分成三種角色:

\begin{conceptbox}{MVC 三種角色}
\begin{itemize}
  \item \textbf{Model(模型)}:資料本身。在本專案中就是 Core Data 的 \code{TodoItem}(一筆待辦事項)。
  \item \textbf{View(視圖)}:使用者看到的畫面。在本專案中就是 Storyboard 裡的 Table View、儲存格與按鈕。
  \item \textbf{Controller(控制器)}:居中協調者。負責把 Model 的資料顯示到 View 上,並把使用者在 View 上的操作(點擊、滑動)轉成對 Model 的修改。本專案的 \code{ViewController} 就是這個角色。
\end{itemize}
\end{conceptbox}

\section{App 從啟動到顯示畫面的流程}

使用者點擊 App 圖示後,系統依序執行:

\begin{enumerate}
  \item 系統載入 App,呼叫 \code{AppDelegate} 的 \code{didFinishLaunchingWithOptions}(App 級初始化)。
  \item 系統建立 Scene(視窗),呼叫 \code{SceneDelegate} 的 \code{willConnectTo}。
  \item 因為 \texttt{Info.plist} 中指定了主 Storyboard 為 \texttt{Main},系統自動載入 \texttt{Main.storyboard},並實例化其「初始視圖控制器(Initial View Controller)」。
  \item 本專案的初始控制器是一個 \textbf{Navigation Controller},它再載入其根控制器——我們的 \code{ViewController}。
  \item \code{ViewController} 的 \code{viewDidLoad()} 被呼叫,我們在這裡做畫面初始化與讀取資料。
\end{enumerate}

\section{UITableView 的運作原理}

待辦清單的核心元件是 \code{UITableView}(表格視圖)。它的運作方式和一般直覺不同——\textbf{Table View 不會自己知道要顯示什麼},而是透過「委任(Delegation)」模式反過來問你:

\begin{conceptbox}{Delegation(委任)模式}
Table View 定義了兩份「協定(Protocol)」:
\begin{itemize}
  \item \code{UITableViewDataSource}(資料來源):Table View 問你「有幾列?」「第 3 列長什麼樣子?」你必須實作對應方法來回答。
  \item \code{UITableViewDelegate}(委任):Table View 通知你「使用者點了第 2 列」「使用者在第 5 列向左滑動」,你實作對應方法來回應。
\end{itemize}
只要把 \code{ViewController} 設成 Table View 的 dataSource 和 delegate,這些問題就會傳給它。
\end{conceptbox}

另外,Table View 使用「\textbf{儲存格重用(Cell Reuse)}」機制:畫面上只需要十幾個儲存格,滑出畫面的儲存格會被回收、換上新資料後重新使用,因此就算清單有一萬筆資料也不會耗盡記憶體。

% =========================================================
\chapter{用 Storyboard 設計介面}
% =========================================================

本章在 \texttt{Main.storyboard} 中完成主畫面的設計。請在左側導覽區點開 \texttt{Main.storyboard},進入視覺化編輯模式。

\section{步驟一:嵌入 Navigation Controller}

我們希望畫面上方有一條導覽列(顯示標題「待辦事項」和「+」按鈕),因此要把現有的 View Controller 包進一個 Navigation Controller:

\begin{enumerate}
  \item 在 Storyboard 中點選現有的 \textbf{View Controller}。
  \item 選單列選擇 \textbf{Editor → Embed In → Navigation Controller}。
  \item Xcode 會自動加入一個 Navigation Controller,並把「初始視圖控制器」的箭頭移到它身上。
  \item 點選 Navigation Controller 裡的 \textbf{Navigation Bar},在右側屬性檢查器勾選 \textbf{Prefers Large Titles},讓標題以 iOS 原生風格的大字顯示。
\end{enumerate}

\section{步驟二:設定導覽列標題與「+」按鈕}

\begin{enumerate}
  \item 點選 View Controller 上方的 \textbf{Navigation Item},在右側屬性檢查器把 \textbf{Title} 改為「待辦事項」。
  \item 打開右上角的 \textbf{元件庫(Library,快捷鍵 Cmd + Shift + L)},搜尋 \textbf{Bar Button Item},拖到導覽列的\textbf{右側}。
  \item 選取這個 Bar Button Item,在屬性檢查器中把 \textbf{System Item} 設為 \textbf{Add},它就會變成系統標準的「+」圖示。
\end{enumerate}

\section{步驟三:加入 Table View 與 Auto Layout 約束}

\begin{enumerate}
  \item 從元件庫拖一個 \textbf{Table View} 到 View Controller 的畫面上,調整大小蓋滿整個畫面。
  \item 選取 Table View,點編輯區右下角的 \textbf{Add New Constraints}(十字方框圖示),設定四邊約束:
    \begin{itemize}
      \item \textbf{Top} 貼齊 Safe Area 上緣,間距 0
      \item \textbf{Leading}(左)、\textbf{Trailing}(右)、\textbf{Bottom}(下)貼齊畫面邊緣,間距 0
    \end{itemize}
  \item 在屬性檢查器中,把 Table View 的 \textbf{Style} 設為 \textbf{Inset Grouped}(圓角卡片風格,更接近 iOS 原生質感)。
\end{enumerate}

\begin{conceptbox}{Auto Layout 與 Safe Area}
\textbf{Auto Layout} 是 iOS 的自動排版系統:你不是指定元件的絕對座標,而是描述「約束(Constraints)」——例如「Table View 的左邊貼齊父視圖左邊」。這樣不管是小螢幕 iPhone 還是大螢幕 iPad,畫面都能自動調整。\textbf{Safe Area}(安全區域)則是避開瀏海、動態島與底部手勢條的區域,把內容約束在 Safe Area 內就不會被遮住。
\end{conceptbox}

\section{步驟四:設計 Prototype Cell(原型儲存格)}

\begin{enumerate}
  \item 選取 Table View,在屬性檢查器把 \textbf{Prototype Cells} 數量設為 \textbf{1},Table View 上會出現一個空白儲存格。
  \item 選取這個儲存格(Table View Cell),做兩個設定:
    \begin{itemize}
      \item \textbf{Style} 設為 \textbf{Subtitle}:儲存格會有「主標題+副標題」兩行文字,我們用主標題顯示事項內容、副標題顯示建立時間。
      \item \textbf{Identifier} 填入 \texttt{TodoCell}:這是儲存格的「重用識別碼」,程式碼中會用這個字串向 Table View 索取儲存格,\textbf{兩邊字串必須完全一致}。
    \end{itemize}
\end{enumerate}

\begin{warnbox}
如果程式執行時當機並出現 \texttt{unable to dequeue a cell with identifier TodoCell} 錯誤,就是 Storyboard 裡的 Identifier 和程式碼裡的字串不一致(大小寫、空格都要相同)。
\end{warnbox}

\section{步驟五:連結 IBOutlet 與 IBAction}

Storyboard 上的元件要和程式碼互動,需要建立兩種連結:

\begin{itemize}
  \item \textbf{IBOutlet}:程式碼中的「變數」指向 Storyboard 元件,讓程式可以操作它(例如叫 Table View 重新載入資料)。
  \item \textbf{IBAction}:Storyboard 元件觸發事件時(例如按鈕被點擊),呼叫程式碼中的「方法」。
\end{itemize}

操作方式:

\begin{enumerate}
  \item 開啟 \textbf{Assistant Editor}(雙併排編輯器):按住 \texttt{Option} 點擊 \texttt{ViewController.swift},讓 Storyboard 與程式碼並排顯示。
  \item \textbf{連 Outlet}:按住 \texttt{Control},從 Table View \textbf{拖曳}到程式碼 class 內部,放開後選 \textbf{Outlet},名稱填 \texttt{tableView}。
  \item \textbf{連 Action}:按住 \texttt{Control},從「+」按鈕拖曳到程式碼中,Connection 選 \textbf{Action},名稱填 \texttt{addButtonTapped},Type 選 \texttt{UIBarButtonItem}。
  \item \textbf{連 dataSource 與 delegate}:按住 \texttt{Control},從 Table View 拖曳到畫面上方的 \textbf{黃色 View Controller 圖示},在彈出選單中分別勾選 \textbf{dataSource} 與 \textbf{delegate}(要拖兩次)。
\end{enumerate}

\begin{tipbox}
dataSource 與 delegate 也可以用程式碼設定(\code{tableView.dataSource = self}),但在 Storyboard 專案中習慣直接在介面上連結,程式碼更乾淨。
\end{tipbox}

\section{完成後的 Storyboard 結構}

完成後,你的 Storyboard 應該包含這樣的階層:

\begin{minted}[linenos=false]{text}
Navigation Controller(初始視圖控制器)
└── ViewController(標題:待辦事項)
    ├── Navigation Item
    │   └── Bar Button Item(System Item: Add)→ IBAction: addButtonTapped
    └── View
        └── Table View(Style: Inset Grouped)→ IBOutlet: tableView
            │                                  → dataSource / delegate: ViewController
            └── Prototype Cell(Style: Subtitle, Identifier: TodoCell)
\end{minted}

% =========================================================
\chapter{用 Core Data 建立資料模型}
% =========================================================

\section{為什麼需要 Core Data?}

如果只把待辦事項存在陣列(Array)中,App 一關閉資料就消失了。\textbf{Core Data} 是 Apple 官方的資料持久化框架,能把物件存進裝置上的資料庫(底層是 SQLite),並提供查詢、排序、關聯等完整功能。

\begin{conceptbox}{Core Data 三個核心角色}
\begin{itemize}
  \item \textbf{NSPersistentContainer}(持久化容器):Core Data 的總入口,負責載入資料模型、建立底層資料庫。
  \item \textbf{NSManagedObjectContext}(管理物件內容,簡稱 context):像一張「工作草稿紙」——所有新增、修改、刪除都先發生在 context 裡,呼叫 \code{save()} 才真正寫入資料庫。
  \item \textbf{NSManagedObject}(管理物件):資料庫中的一筆資料在程式中的化身。我們的 \code{TodoItem} 就是它的子類別。
\end{itemize}
\end{conceptbox}

\section{建立 TodoItem 實體(Entity)}

\begin{enumerate}
  \item 在左側導覽區點開 \texttt{TodoList\_Storyboard.xcdatamodeld},進入資料模型編輯器。
  \item 點左下角 \textbf{Add Entity},把新實體命名為 \texttt{TodoItem}。
  \item 在 \textbf{Attributes} 區塊點「+」,新增三個屬性:
\end{enumerate}

\begin{table}[H]
\centering
\begin{tabular}{lll}
\toprule
\textbf{屬性名稱} & \textbf{型別} & \textbf{用途} \\
\midrule
\texttt{title} & String & 待辦事項的文字內容 \\
\texttt{isCompleted} & Boolean & 是否已完成(預設 NO) \\
\texttt{createdAt} & Date & 建立時間(用來排序與顯示) \\
\bottomrule
\end{tabular}
\caption{TodoItem 實體的三個屬性}
\end{table}

\begin{enumerate}
  \setcounter{enumi}{3}
  \item 選取 \texttt{TodoItem} 實體,在右側 \textbf{Data Model Inspector} 中確認 \textbf{Codegen} 設為 \textbf{Class Definition}——Xcode 會在建置時自動產生 \code{TodoItem} 類別,我們不需要手寫。
\end{enumerate}

自動產生的類別大致等同於:

\begin{minted}[linenos=false]{swift}
public class TodoItem: NSManagedObject {
    @NSManaged public var title: String?
    @NSManaged public var isCompleted: Bool
    @NSManaged public var createdAt: Date?
}
\end{minted}

\begin{tipbox}
注意 \code{title} 與 \code{createdAt} 的型別是「可選型別(Optional)」\code{String?} 與 \code{Date?}——這是 Core Data 產生程式碼的慣例,表示值有可能是 \code{nil}(不存在)。之後在程式中讀取時,需要用 \code{if let} 或 \code{??} 安全地「解包(unwrap)」。
\end{tipbox}

% =========================================================
\chapter{逐行解說:AppDelegate.swift}
% =========================================================

\texttt{AppDelegate.swift} 大部分由 Xcode 範本產生(因為建立專案時勾選了 Core Data)。理解它是理解 Core Data 運作的第一步。

\section{App 啟動與 Scene 管理}

\begin{minted}[firstnumber=8]{swift}
import UIKit
import CoreData

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
\end{minted}

\begin{linelist}
\lineexp{8}{\code{import UIKit}:匯入 UIKit 框架。UIKit 提供所有介面相關類別(\code{UIViewController}、\code{UITableView}、\code{UIButton} 等),幾乎每個 iOS 介面檔案的第一行都是它。}
\lineexp{9}{\code{import CoreData}:匯入 Core Data 框架,才能使用 \code{NSPersistentContainer} 等類別。}
\lineexp{11}{\code{@main}:屬性標記(attribute),告訴編譯器「App 從這個類別開始執行」。整個專案只能有一個 \code{@main}。}
\lineexp{12}{宣告 \code{AppDelegate} 類別,繼承 \code{UIResponder}(能回應系統事件)並遵循 \code{UIApplicationDelegate} 協定(定義了 App 生命週期的各個回呼方法)。}
\lineexp{16--19}{\code{didFinishLaunchingWithOptions}:App 啟動完成後系統呼叫的第一個方法,適合放「全 App 只需做一次」的初始化。回傳 \code{true} 表示啟動成功。本專案不需要額外初始化,維持範本原樣。}
\end{linelist}

\section{Core Data 堆疊(Stack)}

\begin{minted}[firstnumber=37]{swift}
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "TodoList_Storyboard")
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()
\end{minted}

\begin{linelist}
\lineexp{37}{\code{lazy var}:「延遲初始化」變數——第一次被存取時才會執行後面的閉包來建立值。Core Data 初始化成本較高,用 \code{lazy} 可以避免 App 一啟動就做這件事。注意結尾第 45 行的 \code{}()}——這是「立即執行的閉包」寫法,閉包的回傳值成為變數的值。}
\lineexp{38}{建立 \code{NSPersistentContainer},參數 \code{name} 必須和資料模型檔名一致(\texttt{TodoList\_Storyboard.xcdatamodeld} 去掉副檔名),容器靠這個名字找到模型檔。}
\lineexp{39}{\code{loadPersistentStores}:載入(或第一次執行時建立)底層資料庫檔案。這是非同步操作,完成後呼叫 \code{completionHandler} 閉包。}
\lineexp{40--42}{如果載入失敗(例如磁碟已滿),\code{error} 不為 \code{nil},此處以 \code{fatalError} 直接讓 App 終止並印出錯誤。範本註解也提醒:正式上架的 App 應改為更友善的錯誤處理。}
\lineexp{44}{把設定完成的容器回傳,成為 \code{persistentContainer} 的值。}
\end{linelist}

\section{儲存輔助方法 saveContext()}

\begin{minted}[firstnumber=66]{swift}
    func saveContext () {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
\end{minted}

\begin{linelist}
\lineexp{67}{取得容器的 \code{viewContext}——主執行緒專用的 context,所有和畫面相關的資料操作都用它。}
\lineexp{68}{\code{hasChanges}:只有 context 上真的有未儲存的變更時才執行儲存,避免不必要的寫入。}
\lineexp{69--71}{\code{context.save()} 可能拋出錯誤(throws),所以要放在 \code{do { try ... } catch} 區塊中。\code{try} 標記「這行可能失敗」,失敗時跳到 \code{catch}。}
\lineexp{72--74}{儲存失敗時印出錯誤並終止。這個方法會在 App 進入背景時被 \code{SceneDelegate} 呼叫,做「最後一次保險儲存」。}
\end{linelist}

% =========================================================
\chapter{逐行解說:SceneDelegate.swift}
% =========================================================

自 iOS 13 起,App 的「視窗」由 Scene 管理(一個 App 可以有多個視窗,例如 iPad 分割畫面)。\texttt{SceneDelegate.swift} 幾乎全部維持範本原樣,只需理解兩個重點:

\begin{minted}[firstnumber=10]{swift}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else { return }
    }
\end{minted}

\begin{linelist}
\lineexp{12}{\code{window}:這個 Scene 對應的視窗物件。因為我們使用 Storyboard,系統會\textbf{自動}建立視窗並載入 \texttt{Main.storyboard},不需要手動寫程式。}
\lineexp{15--17}{\code{willConnectTo}:Scene 即將連接(顯示)時呼叫。\code{guard let} 是 Swift 的「提前退出」語法:嘗試把 \code{scene} 轉型成 \code{UIWindowScene},失敗就直接 \code{return}。範本在此不做額外設定。}
\end{linelist}

\begin{minted}[firstnumber=44]{swift}
    func sceneDidEnterBackground(_ scene: UIScene) {
        (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
    }
\end{minted}

\begin{linelist}
\lineexp{44}{\code{sceneDidEnterBackground}:使用者切到別的 App 或回主畫面時呼叫。}
\lineexp{45}{透過 \code{UIApplication.shared.delegate} 取得 AppDelegate,呼叫上一章的 \code{saveContext()},確保進背景前所有未儲存的變更都寫入資料庫。\code{as?} 是安全轉型(失敗回傳 \code{nil}),配合 \code{?.} 可選鏈:如果轉型失敗,整行安靜地不執行、不會當機。}
\end{linelist}

% =========================================================
\chapter{逐行解說:ViewController.swift(本教材核心)}
% =========================================================

這是我們自己撰寫的主要檔案,包含 App 的所有功能邏輯。以下依照程式碼順序,分段逐行解說。

\section{匯入框架與類別宣告}

\begin{minted}[firstnumber=8]{swift}
import UIKit
import CoreData

class ViewController: UIViewController {
\end{minted}

\begin{linelist}
\lineexp{8--9}{匯入 UIKit(介面)與 CoreData(資料儲存)兩個框架。}
\lineexp{11}{宣告 \code{ViewController} 類別,繼承自 \code{UIViewController}——所有「管理一個畫面」的類別都繼承它。Storyboard 中的畫面已透過 Identity Inspector 的 Custom Class 設定指向這個類別。}
\end{linelist}

\section{IBOutlet 與屬性宣告}

\begin{minted}[firstnumber=13]{swift}
    // MARK: - IBOutlet

    /// 與 Storyboard 中的 Table View 連結的 Outlet
    @IBOutlet weak var tableView: UITableView!

    // MARK: - 屬性

    /// 存放所有待辦事項的陣列(資料來源)
    var todoItems: [TodoItem] = []

    /// Core Data 的管理物件內容(Managed Object Context),
    /// 透過 AppDelegate 的 persistentContainer 取得
    var context: NSManagedObjectContext {
        (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    }
\end{minted}

\begin{linelist}
\lineexp{13}{\code{// MARK: -} 是給 Xcode 看的特殊註解:會在編輯器上方的跳轉選單中產生分節標記,方便在長檔案中快速導覽。}
\lineexp{16}{\code{@IBOutlet}:標記這個屬性可以連到 Storyboard 元件(IB = Interface Builder)。\code{weak}:弱參照,避免「循環參照」造成記憶體無法釋放(視圖層級已強持有這個 Table View,Outlet 只需弱參照即可)。結尾的 \code{!} 是「隱式解包可選型別」:宣告時還沒有值,但我們保證 Storyboard 載入後它一定存在。}
\lineexp{21}{\code{todoItems}:型別為 \code{[TodoItem]} 的陣列,初始為空陣列 \code{[]}。這是畫面顯示的\textbf{資料來源}——Table View 顯示幾列、每列顯示什麼,都以這個陣列為準。}
\lineexp{25--27}{\code{context} 是「計算屬性(computed property)」:沒有自己儲存值,每次讀取時執行大括號中的程式來取值。這裡透過 AppDelegate 拿到全 App 共用的 \code{viewContext}。\code{as!} 是強制轉型——因為我們確定 delegate 一定是 \code{AppDelegate},可以放心使用。}
\end{linelist}

\section{空狀態標籤與日期格式器}

\begin{minted}[firstnumber=29]{swift}
    /// 當清單為空時顯示的提示標籤
    let emptyStateLabel: UILabel = {
        let label = UILabel()
        label.text = "目前沒有待辦事項\n點右上角「+」新增一筆吧!"
        label.numberOfLines = 0
        label.textAlignment = .center
        label.textColor = .secondaryLabel
        label.font = .preferredFont(forTextStyle: .body)
        return label
    }()

    /// 用來把 Date 轉成易讀字串的日期格式器
    let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .short
        formatter.locale = Locale(identifier: "zh_Hant_TW")
        return formatter
    }()
\end{minted}

\begin{linelist}
\lineexp{30}{和 AppDelegate 的 \code{persistentContainer} 一樣,使用「閉包立即執行」的寫法(結尾 \code{}()})來初始化屬性:在閉包裡建立物件、設定好所有屬性、再回傳。這是 Swift 常見的初始化慣用寫法,比在 \code{viewDidLoad} 中逐行設定更整潔。}
\lineexp{31}{建立一個 \code{UILabel}(文字標籤)。}
\lineexp{32}{設定顯示文字,\code{\\n} 是換行符號。}
\lineexp{33}{\code{numberOfLines = 0} 表示「不限行數」,文字可自動換行。}
\lineexp{34}{文字置中對齊。\code{.center} 是 \code{NSTextAlignment.center} 的簡寫——Swift 能從屬性型別推斷列舉,所以可以省略型別名稱。}
\lineexp{35}{\code{.secondaryLabel} 是系統的「次要文字」語意顏色,會自動配合深色/淺色模式變化。}
\lineexp{36}{\code{preferredFont(forTextStyle:)} 使用「動態字型」:字級跟隨使用者在系統設定中的文字大小偏好,是無障礙設計的最佳實踐。}
\lineexp{41--47}{\code{DateFormatter} 把 \code{Date} 物件轉成人類可讀的字串。\code{dateStyle = .medium} 顯示「2026年7月15日」、\code{timeStyle = .short} 顯示「下午2:38」。\code{locale} 指定臺灣繁體中文格式。建立 DateFormatter 的成本不低,宣告成屬性重複使用(而不是每次都建立新的)是重要的效能習慣。}
\end{linelist}

\section{viewDidLoad:畫面初始化}

\begin{minted}[firstnumber=51]{swift}
    override func viewDidLoad() {
        super.viewDidLoad()

        // 讓導覽列顯示大標題
        navigationController?.navigationBar.prefersLargeTitles = true

        // 設定空狀態提示為 Table View 的背景視圖
        tableView.backgroundView = emptyStateLabel

        // 從 Core Data 讀取已儲存的待辦事項
        fetchTodoItems()
    }
\end{minted}

\begin{linelist}
\lineexp{51}{\code{override}:覆寫父類別 \code{UIViewController} 的方法。\code{viewDidLoad()} 在\textbf{視圖載入完成後}被系統呼叫一次,是做畫面初始化的標準位置。}
\lineexp{52}{先呼叫 \code{super.viewDidLoad()},讓父類別完成它自己的初始化——覆寫生命週期方法時務必記得這行。}
\lineexp{55}{\code{navigationController?} 取得包住自己的導覽控制器(可選鏈:若不存在則整行跳過),開啟大標題模式。}
\lineexp{58}{把空狀態標籤設為 Table View 的 \code{backgroundView}——它會顯示在儲存格「後面」,之後我們用顯示/隱藏它來呈現空狀態。}
\lineexp{61}{呼叫自訂方法讀取資料(下一節解說)。}
\end{linelist}

\section{fetchTodoItems:從 Core Data 讀取資料}

\begin{minted}[firstnumber=67]{swift}
    /// 從 Core Data 讀取所有待辦事項,並依建立時間排序(新的在前)
    func fetchTodoItems() {
        let request: NSFetchRequest<TodoItem> = TodoItem.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: false)]

        do {
            todoItems = try context.fetch(request)
        } catch {
            print("讀取資料失敗:\(error)")
        }

        // 依資料是否為空,決定要不要顯示空狀態提示
        emptyStateLabel.isHidden = !todoItems.isEmpty
        tableView.reloadData()
    }
\end{minted}

\begin{linelist}
\lineexp{69}{建立「擷取請求(Fetch Request)」,泛型 \code{<TodoItem>} 指明「我要查詢 TodoItem 這種實體」。\code{TodoItem.fetchRequest()} 是 Xcode 自動產生的便利方法。}
\lineexp{70}{設定排序描述器:依 \code{createdAt} 欄位排序,\code{ascending: false} 表示遞減——最新建立的排在最上面。注意 \code{sortDescriptors} 是陣列,可以指定多重排序條件。}
\lineexp{72--76}{\code{context.fetch(request)} 執行查詢並回傳結果陣列,指派給 \code{todoItems}。查詢可能失敗(throws),因此包在 \code{do-catch} 中;失敗時用 \code{print} 印出錯誤方便除錯,\code{\\(error)} 是「字串插值」——把變數值嵌入字串。}
\lineexp{79}{一行完成空狀態切換:\code{todoItems.isEmpty} 為真(沒有資料)時,\code{!} 取反得 \code{false},即 \code{isHidden = false}——顯示提示。有資料時則隱藏。}
\lineexp{80}{\code{reloadData()}:命令 Table View 重新向 dataSource 詢問所有資料並重繪畫面。\textbf{修改 \code{todoItems} 後一定要呼叫這行,畫面才會更新。}}
\end{linelist}

\section{saveAndReload:儲存並更新畫面}

\begin{minted}[firstnumber=83]{swift}
    /// 將 context 中的變更寫入永久儲存區,並重新載入畫面
    func saveAndReload() {
        do {
            try context.save()
        } catch {
            print("儲存資料失敗:\(error)")
        }
        fetchTodoItems()
    }
\end{minted}

\begin{linelist}
\lineexp{85--89}{\code{context.save()} 把 context 裡累積的所有變更(新增、修改、刪除)一次寫入資料庫。同樣用 \code{do-catch} 處理可能的錯誤。}
\lineexp{90}{儲存後重新查詢並刷新畫面。把「儲存+刷新」包成一個方法,之後新增、完成、編輯、刪除四個功能都能重複使用——這就是 \textbf{DRY(Don't Repeat Yourself)}原則。}
\end{linelist}

\section{addButtonTapped:新增待辦事項}

\begin{minted}[firstnumber=95]{swift}
    /// 使用者點擊右上角「+」按鈕時呼叫,跳出輸入視窗新增待辦事項
    @IBAction func addButtonTapped(_ sender: UIBarButtonItem) {
        let alert = UIAlertController(title: "新增待辦事項",
                                      message: "請輸入事項內容",
                                      preferredStyle: .alert)

        // 在 Alert 中加入一個文字輸入框
        alert.addTextField { textField in
            textField.placeholder = "例如:買牛奶"
        }

        // 「新增」按鈕:取得輸入文字並建立新的 TodoItem
        let addAction = UIAlertAction(title: "新增", style: .default) { [weak self] _ in
            guard let self = self,
                  let text = alert.textFields?.first?.text?
                      .trimmingCharacters(in: .whitespacesAndNewlines),
                  !text.isEmpty else { return }

            // 在 Core Data 的 context 中建立一筆新資料
            let newItem = TodoItem(context: self.context)
            newItem.title = text
            newItem.isCompleted = false
            newItem.createdAt = Date()

            self.saveAndReload()
        }

        // 「取消」按鈕:不做任何事
        let cancelAction = UIAlertAction(title: "取消", style: .cancel)

        alert.addAction(addAction)
        alert.addAction(cancelAction)
        present(alert, animated: true)
    }
\end{minted}

\begin{linelist}
\lineexp{96}{\code{@IBAction}:標記這個方法可連到 Storyboard 事件——「+」按鈕被點擊時系統會呼叫它。參數 \code{sender} 是觸發事件的元件;參數名前的底線 \code{_} 表示呼叫時不需寫參數標籤。}
\lineexp{97--99}{建立 \code{UIAlertController}(系統彈窗),\code{preferredStyle: .alert} 是置中對話框樣式(另一種 \code{.actionSheet} 是從底部升起的選單)。}
\lineexp{102--104}{\code{addTextField} 在彈窗中加入文字輸入框。大括號是「尾隨閉包(trailing closure)」——當閉包是最後一個參數時,可以寫在括號外面。閉包參數 \code{textField} 是系統建立好的輸入框,我們設定它的佔位提示文字。}
\lineexp{107}{建立「新增」按鈕。第三個參數(尾隨閉包)是按鈕被點擊時要執行的程式。\code{[weak self]} 是「捕捉列表」:閉包以\textbf{弱參照}捕捉 \code{self},避免閉包與控制器互相強持有造成記憶體洩漏——\textbf{在逃逸閉包中使用 self 時的標準防護寫法}。閉包參數用 \code{_} 忽略(我們不需要用到該 action 物件本身)。}
\lineexp{108--111}{\code{guard let} 多條件安全檢查,任何一項失敗就 \code{return}:(1) \code{self} 還存在;(2) 成功取得輸入框文字並用 \code{trimmingCharacters} 去除前後空白與換行;(3) 處理後的文字不是空字串。這確保使用者輸入純空白時不會新增空事項。}
\lineexp{114}{\textbf{Core Data 新增資料的關鍵語法}:\code{TodoItem(context:)} 在指定的 context 中建立一筆新的管理物件。注意——此時資料只存在於「草稿」中,尚未寫入資料庫。}
\lineexp{115--117}{設定三個屬性:標題、未完成、建立時間為現在(\code{Date()} 產生當下時間)。}
\lineexp{119}{呼叫 \code{saveAndReload()}——這時才真正寫入資料庫並刷新畫面。}
\lineexp{123}{「取消」按鈕使用 \code{.cancel} 樣式(粗體顯示),不傳入閉包表示點了不做任何事、只關閉彈窗。}
\lineexp{125--127}{把兩個按鈕加入彈窗,最後用 \code{present(_:animated:)} 以動畫顯示彈窗。}
\end{linelist}

\section{presentEditAlert:編輯既有事項}

\begin{minted}[firstnumber=132]{swift}
    /// 跳出輸入視窗,讓使用者修改既有事項的標題
    func presentEditAlert(for item: TodoItem) {
        let alert = UIAlertController(title: "編輯待辦事項",
                                      message: "請修改事項內容",
                                      preferredStyle: .alert)

        alert.addTextField { textField in
            textField.text = item.title
        }

        let saveAction = UIAlertAction(title: "儲存", style: .default) { [weak self] _ in
            guard let self = self,
                  let text = alert.textFields?.first?.text?
                      .trimmingCharacters(in: .whitespacesAndNewlines),
                  !text.isEmpty else { return }

            item.title = text
            self.saveAndReload()
        }

        alert.addAction(saveAction)
        alert.addAction(UIAlertAction(title: "取消", style: .cancel))
        present(alert, animated: true)
    }
\end{minted}

\begin{linelist}
\lineexp{133}{參數標籤 \code{for item: TodoItem}:外部呼叫時寫 \code{presentEditAlert(for: 某事項)},讀起來像自然語言——這是 Swift 的 API 命名慣例。}
\lineexp{139}{與新增不同:把輸入框的初始文字設為\textbf{現有的}標題,讓使用者在原文字上修改。}
\lineexp{148}{\textbf{Core Data 修改資料就是這麼簡單}:直接改物件的屬性即可,context 會自動追蹤這筆物件「已被修改」,下次 \code{save()} 時寫入。}
\end{linelist}

\section{extension 與 UITableViewDataSource}

\begin{minted}[firstnumber=155]{swift}
// MARK: - UITableViewDataSource

extension ViewController: UITableViewDataSource {

    /// 回傳列表的列數 = 待辦事項的數量
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return todoItems.count
    }
\end{minted}

\begin{linelist}
\lineexp{157}{\code{extension}(擴展):在類別本體之外為它「加掛」功能。慣例上,每個協定的實作用一個獨立的 extension 包起來,讓程式碼分區清楚。這行表示「ViewController 遵循 UITableViewDataSource 協定,實作寫在這個區塊」。}
\lineexp{160--162}{\textbf{DataSource 必答題之一}:「這個表格有幾列?」我們回答:\code{todoItems} 陣列有幾個元素就有幾列。\code{section} 是分區編號——本 App 只有一個分區,不需要理會它。}
\end{linelist}

\begin{minted}[firstnumber=164]{swift}
    /// 設定每一列儲存格的內容
    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // 從重用佇列取出識別碼為 "TodoCell" 的儲存格
        let cell = tableView.dequeueReusableCell(withIdentifier: "TodoCell",
                                                 for: indexPath)
        let item = todoItems[indexPath.row]

        // 已完成的事項:加上刪除線並變灰
        let title = item.title ?? ""
        if item.isCompleted {
            let attributed = NSAttributedString(
                string: title,
                attributes: [
                    .strikethroughStyle: NSUnderlineStyle.single.rawValue,
                    .foregroundColor: UIColor.secondaryLabel
                ])
            cell.textLabel?.attributedText = attributed
        } else {
            cell.textLabel?.attributedText = nil
            cell.textLabel?.text = title
            cell.textLabel?.textColor = .label
        }

        // 副標題顯示建立時間
        if let createdAt = item.createdAt {
            cell.detailTextLabel?.text = dateFormatter.string(from: createdAt)
        } else {
            cell.detailTextLabel?.text = nil
        }
        cell.detailTextLabel?.textColor = .secondaryLabel

        // 已完成的事項在右側顯示勾勾
        cell.accessoryType = item.isCompleted ? .checkmark : .none

        return cell
    }
}
\end{minted}

\begin{linelist}
\lineexp{165--166}{\textbf{DataSource 必答題之二}:「第 N 列的儲存格長什麼樣?」Table View 每次要顯示某一列時就呼叫這個方法。\code{indexPath} 包含 \code{section}(分區)與 \code{row}(列號)。}
\lineexp{168--169}{\code{dequeueReusableCell}:從「重用佇列」取出識別碼為 \texttt{TodoCell} 的儲存格(就是我們在 Storyboard 設計的 Prototype Cell)。如果佇列裡有被回收的舊儲存格就直接重用,沒有才建立新的——這就是第三章提到的儲存格重用機制。}
\lineexp{170}{用列號從陣列取出對應的事項:第 0 列對應 \code{todoItems[0]},以此類推。}
\lineexp{173}{\code{??} 是「nil 聚合運算子」:\code{item.title} 是 \code{String?},若為 \code{nil} 則改用空字串,確保 \code{title} 一定有值。}
\lineexp{174--181}{若事項已完成,建立 \code{NSAttributedString}(帶樣式的字串):加上單線刪除線(\code{.strikethroughStyle})並把文字設為次要灰色,指派給主標題的 \code{attributedText}。}
\lineexp{182--186}{\textbf{重用機制的重要細節}:若事項未完成,必須把 \code{attributedText} 明確設回 \code{nil}、重設文字與顏色。因為這個儲存格可能是「回收再利用」的——若不清除,上一筆已完成事項的刪除線樣式會殘留在這筆未完成的事項上,造成經典的「儲存格內容錯亂」bug。}
\lineexp{189--193}{用 \code{if let} 解包 \code{createdAt},有值就交給第八章宣告的 \code{dateFormatter} 轉成「2026年7月15日 下午2:38」格式,顯示在副標題。}
\lineexp{197}{三元運算子 \code{條件 ? A : B}:已完成顯示系統勾勾(\code{.checkmark}),否則不顯示配件。}
\lineexp{199}{把設定完成的儲存格回傳給 Table View 顯示。}
\end{linelist}

\section{UITableViewDelegate:點擊與滑動操作}

\begin{minted}[firstnumber=205]{swift}
// MARK: - UITableViewDelegate

extension ViewController: UITableViewDelegate {

    /// 點擊某一列:切換「已完成 / 未完成」狀態
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)

        let item = todoItems[indexPath.row]
        item.isCompleted.toggle()
        saveAndReload()
    }
\end{minted}

\begin{linelist}
\lineexp{210}{\code{didSelectRowAt}:使用者點擊某一列時,Table View 透過 delegate 通知我們。}
\lineexp{211}{\code{deselectRow}:取消該列的「選取反白」狀態並附帶淡出動畫——不做這行,該列會一直維持灰底。}
\lineexp{213--214}{取出被點擊的事項,\code{toggle()} 把布林值反轉(true 變 false、false 變 true)——一行完成「切換完成狀態」。}
\lineexp{215}{儲存並刷新,畫面上立即出現(或移除)刪除線與勾勾。}
\end{linelist}

\begin{minted}[firstnumber=218]{swift}
    /// 向左滑動:顯示「刪除」按鈕
    func tableView(_ tableView: UITableView,
                   trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath)
    -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive,
                                              title: "刪除") { [weak self] _, _, completion in
            guard let self = self else { return }

            // 從 Core Data 中刪除該筆資料
            let item = self.todoItems[indexPath.row]
            self.context.delete(item)
            self.saveAndReload()
            completion(true)
        }
        return UISwipeActionsConfiguration(actions: [deleteAction])
    }

    /// 向右滑動:顯示「編輯」按鈕
    func tableView(_ tableView: UITableView,
                   leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath)
    -> UISwipeActionsConfiguration? {
        let editAction = UIContextualAction(style: .normal,
                                            title: "編輯") { [weak self] _, _, completion in
            guard let self = self else { return }
            self.presentEditAlert(for: self.todoItems[indexPath.row])
            completion(true)
        }
        editAction.backgroundColor = .systemBlue
        return UISwipeActionsConfiguration(actions: [editAction])
    }
}
\end{minted}

\begin{linelist}
\lineexp{219--221}{\code{trailingSwipeActions...}:設定「從尾端滑入」(在由左至右的語言環境即\textbf{向左滑})出現的按鈕。回傳型別 \code{UISwipeActionsConfiguration?} 可為 \code{nil}(表示該列不提供滑動操作)。}
\lineexp{222--223}{建立情境操作按鈕:\code{.destructive} 樣式自動顯示為\textbf{紅色},代表破壞性操作。閉包的三個參數依序是 action 本身、按鈕視圖、完成回呼——前兩個用 \code{_} 忽略。}
\lineexp{227--229}{\textbf{Core Data 刪除資料}:\code{context.delete(item)} 把該物件標記為刪除,\code{save()} 時真正從資料庫移除。之後 \code{saveAndReload()} 會重新查詢,畫面上該列即消失。}
\lineexp{230}{呼叫 \code{completion(true)} 告訴系統「操作已完成」,讓滑動選單收合。}
\lineexp{232}{把按鈕包進 \code{UISwipeActionsConfiguration} 回傳,陣列可放多個按鈕。}
\lineexp{236--238}{\code{leadingSwipeActions...}:「從前端滑入」(即\textbf{向右滑})的按鈕,用來放「編輯」。}
\lineexp{239--244}{\code{.normal} 樣式預設灰色,第 245 行手動改為系統藍色。點擊後呼叫前面寫好的 \code{presentEditAlert(for:)} 彈出編輯視窗。}
\end{linelist}

\begin{tipbox}
你可能注意到一個模式:\textbf{所有資料操作都遵循同一個流程}——修改 context(新增/改屬性/刪除)→ \code{saveAndReload()} → 畫面自動更新。掌握這個「單向資料流」心智模型,Core Data 就不再神祕。
\end{tipbox}

% =========================================================
\chapter{建置、執行與自動化測試}
% =========================================================

\section{在模擬器上執行}

\begin{enumerate}
  \item 在 Xcode 上方工具列的裝置選單中選擇一台模擬器(例如 \textbf{iPhone 17})。
  \item 按 \textbf{Run}(\texttt{Cmd + R})。Xcode 會編譯程式、啟動模擬器並安裝執行 App。
  \item 測試所有功能:新增幾筆事項、點擊切換完成、左滑刪除、右滑編輯。
  \item 按模擬器的 Home(\texttt{Cmd + Shift + H})把 App 收到背景,再從 Xcode 重新執行——確認資料仍然存在,驗證 Core Data 儲存成功。
\end{enumerate}

也可以使用終端機以指令建置(進階;適合自動化):

\begin{minted}[linenos=false]{bash}
xcodebuild -project TodoList-Storyboard.xcodeproj \
           -scheme TodoList-Storyboard \
           -destination 'platform=iOS Simulator,name=iPhone 17' \
           build
\end{minted}

\section{撰寫 UI 自動化測試}

專案的 \texttt{TodoList-StoryboardUITests} target 可以撰寫「模擬真人操作」的自動化測試。以下測試會自動點擊「+」、輸入文字、驗證清單內容,並點擊標記完成:

\begin{minted}{swift}
import XCTest

final class TodoList_StoryboardUITests: XCTestCase {

    override func setUpWithError() throws {
        continueAfterFailure = false
    }

    /// 完整流程測試:新增兩筆待辦事項,並將其中一筆標記為完成
    @MainActor
    func testAddAndCompleteTodoItems() throws {
        let app = XCUIApplication()
        app.launch()

        // 新增第一筆:買牛奶
        app.navigationBars.buttons["Add"].tap()
        let textField = app.alerts.textFields.firstMatch
        XCTAssertTrue(textField.waitForExistence(timeout: 3))
        textField.typeText("買牛奶")
        app.alerts.buttons["新增"].tap()

        // 新增第二筆:寫 iOS 作業
        app.navigationBars.buttons["Add"].tap()
        XCTAssertTrue(textField.waitForExistence(timeout: 3))
        textField.typeText("寫 iOS 作業")
        app.alerts.buttons["新增"].tap()

        // 確認兩筆都出現在列表上
        XCTAssertTrue(app.staticTexts["買牛奶"].waitForExistence(timeout: 3))
        XCTAssertTrue(app.staticTexts["寫 iOS 作業"].exists)

        // 點擊「買牛奶」把它標記為已完成
        app.staticTexts["買牛奶"].tap()

        sleep(1)
    }
}
\end{minted}

重點語法說明:

\begin{itemize}
  \item \code{XCUIApplication().launch()}:啟動被測試的 App。
  \item \code{app.navigationBars.buttons["Add"]}:以「無障礙識別」查詢介面元素——系統的 Add 按鈕識別名稱是 \texttt{"Add"}。
  \item \code{waitForExistence(timeout:)}:等待元素出現(最多等 3 秒),處理動畫等非同步狀況。
  \item \code{XCTAssertTrue(...)}:斷言——條件不成立則測試失敗。
  \item \code{typeText(...)} 與 \code{tap()}:模擬打字與點擊。
\end{itemize}

在 Xcode 中打開測試檔案,點方法左側的菱形按鈕即可執行;本專案實測\textbf{測試通過(TEST SUCCEEDED)}。

\section{常見錯誤排查}

\begin{table}[H]
\centering
\begin{tabular}{p{6.8cm}p{7.7cm}}
\toprule
\textbf{症狀} & \textbf{原因與解法} \\
\midrule
啟動即當機:\texttt{this class is not key value coding-compliant for the key ...} & Storyboard 的 Outlet 連到已改名或刪除的屬性。在 Storyboard 中右鍵檢查元件的連結,刪除黃色驚嘆號的失效連結後重連。 \\
當機:\texttt{unable to dequeue a cell with identifier} & 程式碼中的識別碼字串與 Prototype Cell 的 Identifier 不一致。 \\
點「+」新增後畫面沒反應 & 忘記呼叫 \code{reloadData()},或 dataSource 沒有連到 ViewController。 \\
重開 App 資料消失 & 忘記呼叫 \code{context.save()}(本專案封裝在 \code{saveAndReload()} 中)。 \\
畫面上儲存格樣式錯亂(刪除線出現在錯的列) & 重用儲存格時沒有重設樣式,參考 \code{cellForRowAt} 中 else 分支的解說。 \\
\bottomrule
\end{tabular}
\caption{常見錯誤與解法}
\end{table}

% =========================================================
\chapter{總結與延伸練習}
% =========================================================

\section{你學會了什麼}

\begin{itemize}
  \item 用 Xcode 建立 Storyboard + Core Data 的 iOS 專案。
  \item Storyboard 介面設計:Navigation Controller、Bar Button Item、Table View、Prototype Cell、Auto Layout 約束。
  \item IBOutlet/IBAction 的連結,以及 dataSource/delegate 的委任模式。
  \item Core Data 完整 CRUD:\code{TodoItem(context:)} 新增、直接改屬性修改、\code{context.delete()} 刪除、\code{NSFetchRequest} 查詢與排序、\code{context.save()} 儲存。
  \item Swift 核心語法:可選型別與解包(\code{?}、\code{!}、\code{if let}、\code{guard let}、\code{??})、閉包與 \code{[weak self]}、計算屬性、\code{lazy}、extension、三元運算子、字串插值。
  \item UI 細節:NSAttributedString 刪除線、滑動操作按鈕、空狀態設計、動態字型。
  \item XCUITest 自動化 UI 測試。
\end{itemize}

\section{延伸練習(由易到難)}

\begin{enumerate}
  \item \textbf{到期日}:為 \code{TodoItem} 增加 \code{dueDate} 屬性,新增時用 \code{UIDatePicker} 選日期,過期事項顯示紅字。
  \item \textbf{分區顯示}:把清單分成「未完成」與「已完成」兩個 section(提示:實作 \code{numberOfSections} 與 \code{titleForHeaderInSection})。
  \item \textbf{搜尋}:加入 \code{UISearchController},用 \code{NSPredicate} 過濾 Core Data 查詢結果。
  \item \textbf{拖曳排序}:實作 \code{moveRowAt} 讓使用者長按拖曳調整順序(需要增加 \code{sortIndex} 屬性)。
  \item \textbf{NSFetchedResultsController}:用它取代手動的 \code{fetchTodoItems()},讓 Core Data 變更自動驅動 Table View 的插入/刪除動畫。
  \item \textbf{iCloud 同步}:改用 \code{NSPersistentCloudKitContainer} 作為持久化容器,在多台裝置間同步資料。
\end{enumerate}

\begin{center}
\vspace{1cm}
{\sffamily\Large 恭喜完成你的第一個 iOS App!}
\end{center}

\end{document}

Articles liés