【Android 架構系列 01】幻覺與陷阱:為什麼 AI 寫得越快,技術債還得越多?

01. 前言:那種「我是神」的錯覺
2024 年以後,Android 開發者的日常發生了劇變。
回想一下你第一次深度使用 GitHub Copilot、Cursor 或 Android Studio 內建 AI 的體驗。你打開一個新的 Compose 專案,輸入一行 Prompt: 用 NewsAPI 抓取特斯拉的新聞,用 Compose 顯示列表和圖片,要處理錯誤
按下 Tab 鍵。短短 30 秒,螢幕上飛快地生成了 OkHttp 請求、JSONObject 解析迴圈、LazyColumn 排版,甚至連 AsyncImage 都幫你寫好了。你按下了 Run,App 跑起來了,新聞列表完美顯示。
那一刻,你覺得自己無所不能。以前要寫 RecyclerView Adapter、Retrofit Interface 的痛苦時光一去不復返。
但這是一種危險的「生產力幻覺」。
兩週後,產品經理提了一個需求:「我們需要加入離線瀏覽功能(Room Database),而且如果新聞來源是 'YouTube',點擊要直接跳轉 App,另外請把圖片載入失敗的預設圖換成公司 Logo。」
你自信滿滿地把這段 Compose 代碼再次丟給 AI,並說:「幫我加上資料庫快取與點擊跳轉邏輯。」
這時,災難發生了。 AI 開始瘋狂重寫你的 MainActivity。它試圖在 setContent 裡面初始化 Room Database(導致每次 Recomposition 都重建 DB),或者把點擊邏輯塞進原本就已經很擁擠的 items 迴圈裡。App 開始閃退,UI 狀態亂跳。你發現你花在「修 AI 寫的 Code」的時間,比你自己重寫一遍還要長。
為什麼會這樣?因為你雖然有了法拉利的引擎(AI),但你卻把它裝在一台沒有方向盤的三輪車(無架構的 Code)上。
02. 核心問題:當 AI 遇上 God Activity
在 Compose 時代,我們雖然擺脫了 XML,但卻更容易寫出 "God Activity" 或 "Massive Composable"。
我們來看一個經典的反面教材。這段程式碼在 AI 眼中,是一塊美味但有毒的蛋糕。它違反了 SOLID 中的 SRP (單一職責),將 UI 渲染、網路請求、資料解析 全部混在同一個檔案裡。
// MainActivity.kt
// 這是一個典型的「義大利麵程式碼」,所有邏輯糾纏在一起
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val apiKey = getString(R.string.news_api_key)
// 耦合點 1: 直接依賴具體實作 (Hard Dependency)
// 網路層 (OkHttp) 直接暴露在 UI 層 (Activity)
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
val httpClient = OkHttpClient.Builder().apply { addInterceptor(logging) }.build()
// 狀態變數散落在 Activity 中,難以測試
val articlesState = mutableStateOf<List<Article>>(emptyList())
val errorState = mutableStateOf<String?>(null)
lifecycleScope.launch {
try {
if (apiKey.isBlank()) {
errorState.value = "Please set your NewsAPI key..."
} else {
// 耦合點 2: 邏輯大雜燴
// 在 UI 層直接呼叫 suspend function 進行網路請求與解析
val list = fetchEverything(client = httpClient, apiKey = apiKey)
withContext(Dispatchers.Main) {
articlesState.value = list
}
}
} catch (t: Throwable) {
withContext(Dispatchers.Main) {
errorState.value = t.message ?: "Unknown error"
}
}
}
setContent {
// UI 排版邏輯...
if (errorState.value != null) { /* Error UI */ }
else { NewsList(articles = articlesState.value) }
}
}
}
// 全域函式:無法被 Mock,無法單元測試
suspend fun fetchEverything(client: OkHttpClient, apiKey: String): List<Article> = withContext(Dispatchers.IO) {
// 1. URL 建構邏輯 (硬編碼 parameters)
val url = HttpUrl.Builder().scheme("https").host("newsapi.org")...build()
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { resp ->
// 2. 資料解析邏輯 (手動解析 JSON,極度脆弱)
val json = JSONObject(resp.body?.string())
val articlesJson = json.optJSONArray("articles") ?: return@withContext emptyList()
val list = mutableListOf<Article>()
for (i in 0 until articlesJson.length()) {
// ... 繁瑣的 JSON 取值邏輯 ...
list.add(Article(title, urlToImage, source))
}
return@withContext list
}
}
這段程式碼看起來很方便,複製貼上就能跑。但在 AI 輔助開發的場景下,它是致命的。
03. 實戰演練:AI 協作的「翻車現場」
為什麼上述代碼是災難?讓我們模擬與 AI 的真實對話。
場景 1:上下文污染 (Context Pollution)
NewsAPI 回傳的 title 有時會包含來源(例如:「AI 時代來臨 - TechCrunch」)。產品經理希望把「 - 」後面的文字截掉。
你給 AI 的 Prompt:
"請修改 fetchEverything,在解析 title 時,如果包含 '-' 符號,請只保留 '-' 前面的文字。"
AI 的行為分析: AI 看到你的 fetchEverything 函式裡塞滿了 HttpUrl 的建構細節、JSONObject 的迴圈解析。它的 Context Window (上下文視窗) 被這些底層細節填滿了。
AI 生成的結果 (錯誤示範):
// AI 修改後的 fetchEverything 迴圈
for (i in 0 until articlesJson.length()) {
val item = articlesJson.optJSONObject(i) ?: continue
// AI 改對了這裡:處理字串切割
val rawTitle = item.optString("title")
val title = rawTitle.split("-")[0].trim().ifEmpty { null }
// 災難發生了!
// AI 在重寫這個迴圈時,可能遺漏了原本的 source 解析邏輯
// 或者因為它覺得 sourceName 沒用到 (目前 UI 很簡單),就把它簡化掉了
val urlToImage = item.optString("urlToImage").ifEmpty { null }
// 原本的 val sourceObj = item.optJSONObject("source") ... 被 AI 忽略了
list.add(Article(title = title, urlToImage = urlToImage, source = null))
}
場景 2:擴充功能的噩夢 (違反 OCP)
接下來,我們要加入 Room 資料庫 做離線快取。
你給 AI 的 Prompt:
"請修改 MainActivity,加入 Room 資料庫。當網路請求成功時存入 DB,失敗時從 DB 讀取顯示。"
AI 的行為分析: AI 發現你的網路請求寫在 lifecycleScope.launch 裡,且直接操作 fetchEverything。要加入 Room,它必須在 Activity 裡初始化 Database,並大幅修改原本的流程。
AI 生成的結果:
lifecycleScope.launch {
// AI 試圖在 Coroutine 裡初始化 DB (這是錯誤的,應該在 Application 或 DI 模組)
val db = Room.databaseBuilder(applicationContext, AppDatabase::class.java, "news.db").build()
try {
val list = fetchEverything(httpClient, apiKey)
// AI 插入 DB
db.articleDao().insertAll(list.map { it.toEntity() }) // 這裡還需要寫 Mapper...
withContext(Dispatchers.Main) {
articlesState.value = list
}
} catch (t: Throwable) {
// AI 從 DB 讀取
val cached = withContext(Dispatchers.IO) { db.articleDao().getAll() }
withContext(Dispatchers.Main) {
// ⚠️ 災難:型別不匹配
// DB 回傳的是 ArticleEntity,UI 需要的是 Article
// AI 在這裡經常會寫出編譯錯誤的代碼,或者寫一堆醜陋的 .map { } 轉換
articlesState.value = cached.map { ... }
}
}
}
筆者點評: 這是 違反 OCP (開閉原則) 的後果。 因為沒有 Repository 層來封裝「資料來源的策略」,AI 必須對你的 MainActivity 進行「開腸破肚」式的修改。你的 Activity 變得越來越肥,充滿了 if-else 和 try-catch,最後變成無人敢碰的 God Class。
04. 本質思考:Compose 讓「壞架構」更容易隱形
在 View System 時代,XML 和 Java/Kotlin 檔案是分開的,這多少強迫你分離 UI 和邏輯。 但在 Compose 時代,UI 就是 Kotlin 代碼。這意味著你可以在 Column 裡面直接寫 HttpUrl.Builder(),編譯器完全不會阻止你。
AI 非常喜歡這種「寫在一起」的風格,因為這符合它「預測下一個字」的線性思維。但對人類來說,這就是維護地獄。
05. 結論:速度的代價
我們必須認清一個事實:AI 寫 Code 的速度越快,如果你沒有給它軌道(架構),它製造技術債的速度也越快。
如果把 AI 比作一隻聰明但過動的邊境牧羊犬:
-
沒有架構 (Spaghetti Code): 就像把牧羊犬放在一個充滿瓷器的古董店裡,叫它「去抓老鼠」。它確實抓到了老鼠(完成了功能),但也打碎了一堆花瓶(破壞了現有邏輯)。
-
有架構 (Clean Architecture): 就像把牧羊犬放在一個空曠的草地上,周圍有圍欄。你叫它「去把羊趕進柵欄」。它能高效完成任務,且不會造成破壞。
在 AI 時代,架構師的職責不再是寫出每一行程式碼,而是畫出那個「圍欄」。
那麼,這個「圍欄」該長什麼樣子?我們要如何把抽象的 SOLID 原則,轉化為 AI 能夠聽懂的 Prompt?
在下一篇 【Part 2】與 AI 的契約:重新解讀 SOLID 原則 中,筆者將帶你重新認識這五個老掉牙的原則,你會發現,它們其實是為了 AI 時代而生的 Prompt Engineering 指南。

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。