beio Logobeio

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

發布於

cover

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-elsetry-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

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner