iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Software Development

Codetopia 新手日記:設計模式與原則的 30 天學習之旅系列 第 12

Day 12:Flyweight:城市資產共享中心——一張圖示,千萬位置

  • 分享至 

  • xImage
  •  

Codetopia 創城記 (12)|Flyweight:城市資產共享中心——一張圖示,千萬位置

今日熱點 (故事開場 & 痛點)

Codetopia 的週末總是充滿活力,市府這週隆重推出了「Codetopia City Map 2.0」,主打即時活動地圖,從音樂祭的流動廁所、夜市的特色攤販,到馬拉松路線上的每個補給站,總計超過 80 萬個地標在手機地圖上閃閃發光。

整合工程師 Ken|主視角 正盯著監控面板,眉頭深鎖。旁邊的菜鳥工程師 Andy|陪跑解說 則拿著測試手機,臉色比手機螢幕還慘白。「Ken...學長...你看,只是縮放一下地圖,App 就...就 OOM (Out of Memory) 了!」

Ken 點開記憶體火焰圖,原因昭然若揭。他指著那片熊熊燃燒的紅色區塊,對 Andy 說:「你看這裡,每個地圖標記 (Marker) 都是一個獨立的物件,這沒問題。但問題是,每個物件都各自打包了一份一模一樣的 1.2KB 圖示資料,還有完全相同的樣式設定。等於同一張廁所圖示,在記憶體裡被複製了幾萬次。」

「可是...每個標記的位置和狀態都不一樣啊?」Andy 不解地問。

「問得好!」Ken 拍了拍 Andy 的肩膀,「真正『每個地標都不同』的,只有它的經緯度、即時狀態(例如忙碌中顯示紅色)。圖示本身、樣式那些『不變的大塊頭』,根本沒必要跟著座標跑。別怕標記多,咱們可以讓一張圖示只養一次;其他的地標都只帶著自己的『座標』來借用就好。」

術語卡

🧭 Flyweight(享元模式):將物件中不變、可共享的「內蘊狀態(intrinsic state)」抽離並集中管理,同時把那些「外部才知道的變動值(extrinsic state)」由呼叫端在使用時帶入,以此達到大幅優化海量細粒度物件記憶體佔用的目的。

  • 內蘊 (Intrinsic) vs. 外蘊 (Extrinsic):前者如圖示的像素資料、固定的樣式;後者如 (x,y) 座標、即時指定的顏色或旋轉角度。

  • EIP/EDA (中觀):對應「內容可位址快取(Content-Addressable Store)」,事件或訊息中只傳遞輕量的資產 ID (assetKey),由消費端自行向共享儲存區解參照取得完整內容。

  • MAS (宏觀):由一個 AssetRegistryAgent 專門登記與提供「可共享資產」,而 RenderAgent 則在收到包含座標、樣式(外蘊狀態)的繪製請求後,向 AssetRegistryAgent 引用共享資產來完成工作。

  • SSOT (Single Source of Truth):圖示、樣式等共享資產應有唯一來源,避免因異步更新或多頭馬車造成的風格漂移。

笑中帶淚 (反例/壞味道)

故事倒帶回 Andy 最初的實作版本,那是一個典型的「OOM 製造機」。他為每一個地標都創建了一個完整的 Marker 物件,天真地以為物件導向就是萬物皆物件。

# ❌ 壞味道:把不變的大塊頭放進每一個小物件裡,記憶體被活活撐爆
import os

# 假設這是一個載入圖示的函數
def load_png(name):
    # 模擬回傳 ~1.2KB 的資料(1.2 * 1024 ≈ 1229 bytes)
    return os.urandom(1229)

class Marker:
    def __init__(self, x, y, icon_bytes, style):
        self.x = x
        self.y = y
        self.icon = icon_bytes  # 慘案現場:1.2KB/顆 × 800k = 接近 1GB 的記憶體!
        self.style = style      # {'shape':'pin','border':'#222'} 也被重複存放

# 模擬 80 萬個地標座標
points = [(i, i) for i in range(800000)]

markers = []
# 大家都用同一張圖示和樣式
common_icon = load_png("pin.png")
common_style = {"shape": "pin", "border": "#222"}

for p_x, p_y in points:
    # 每次都把 1.2KB 的圖示和樣式字典塞進新物件
    markers.append(Marker(p_x, p_y, common_icon, common_style))

# 結果:記憶體曲線直衝雲霄,GC (垃圾回收) 哭著表示無能為力。

(旁白:Andy 的程式碼完美示範了如何用一個簡單的迴圈讓一部高效能手機變成磚塊。壯烈。)

估算:1.2KB × 800,000 ≈ 960,000KB ≈ ~0.94GB(僅圖示重複,尚未含樣式與物件額外開銷)

王牌出手 (核心觀念/何時用/不適用)

Flyweight 模式的核心精神是「共享」。它透過一個工廠或登錄表 (Registry) 來產生或取得共享的「內蘊物件」(Flyweight),而呼叫端(例如我們的地圖 App)在需要渲染或使用這些物件時,才動態地注入「外蘊狀態」(例如座標、顏色)。這樣,一個 Flyweight 實例就能為成千上萬的邏輯個體服務,極大地節省了記憶體。

何時用 (When to Use)

  • ✅ 當應用程式需要生成海量「細粒度」物件,且這些物件內部有高比例的狀態是可以共享的(例如:地圖標記、文字編輯器中的字元字形、遊戲中的棋子或粒子系統)。

  • ✅ 當記憶體足跡是主要的效能瓶頸,且我們可以接受在使用點(例如 draw 方法)多傳遞幾個參數(外蘊狀態)。

何時不要用 (When NOT to Use)

  • ⛔ 當物件的主要狀態高度可變,幾乎沒有什麼可以共享的內蘊部分,或者其狀態嚴重依賴上下文,難以由外部簡單提供。

  • ⛔ 當外蘊狀態的綁定成本過高時。例如,取得共享物件後,需要經過複雜的計算或跨執行緒的同步才能確定外蘊狀態,那麼省下來的記憶體可能完全被這些額外的計算或鎖競爭的開銷給抵銷掉。

鄰近模式對照

  • Prototype:專注於透過「複製」來節省昂貴的「初始化」過程。Flyweight 則是根本「不複製」不變的資料,而是直接「共享」。

  • Decorator:專注於動態地為物件「包裝」上新的行為。Flyweight 則是對物件的「狀態」進行內、外分離。

  • 物件池 (Object Pool) / 快取 (Cache):雖然都涉及物件的重用,但它們更關心物件的「生命週期管理」。Flyweight 的核心在於對「狀態」的切割與共享。

導播切景 (表格+三張 Mermaid)

導播,鏡頭拉一下!讓我們從三個層次來看看這個「資產共享中心」是怎麼運作的。先看微觀的類別圖,搞清楚誰負責共享、誰負責注入;再看中觀的訊息流,理解 assetKey 如何被解參照;最後看宏觀視角下,不同職能的代理是如何分工合作的。

層級 對應概念 Codetopia 詞彙
微觀 (GoF) Flyweight Factory & Flyweight Object 資產工廠 (AssetFactory) & 共享圖示 (IconFlyweight)
中觀 (EIP/EDA) Content-Addressable Store / Key-based Retrieval 事件只帶 icon_key,由地圖 App 向資產庫查詢實體
宏觀 (MAS) AssetRegistryAgent & RenderAgent 資產註冊代理 (管共享) & 渲染代理 (管繪製)

5.1 微觀 (GoF) — 類別關係圖

https://ithelp.ithome.com.tw/upload/images/20250926/20178500uPQTCWNN2I.png

5.2 中觀 (EIP/EDA) — 訊息序列圖

https://ithelp.ithome.com.tw/upload/images/20250926/20178500H1mw746t2P.png

5.3 宏觀 (MAS) — 角色協作流程圖

https://ithelp.ithome.com.tw/upload/images/20250926/20178500oITeQ1g5oA.png

最小實作 (程式碼範例)

現在,讓我們用 Ken 的思路來重構 Andy 的程式碼。這是一個加入了併發保護、真正不可變性、以及可觀測性的 Python 風格 pseudo code。

import threading
import collections
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping

# 增加一個計數器來觀測載入次數
load_counter = collections.Counter()

# 假設這是一個從磁碟或網路載入資產的函數,它只在需要時被呼叫一次
def load_asset(key: str):
    load_counter.update([key])  # 每次呼叫都計數
    print(f"Loading asset for key: {key}... (total loads for this key: {load_counter[key]})")
    # 模擬 IO 操作,載入圖示和樣式
    sprite_data = f"SPRITE_DATA_FOR_{key}".encode('utf-8')
    style_data = {"shape": key.split('/')[0], "border": "#222"}
    return sprite_data, style_data

@dataclass(frozen=True)
class RenderParams:
    """ 外蘊狀態包 (Value Object),讓 API 更乾淨 """
    x: float
    y: float
    color: str
    rotation: int = 0

@dataclass(frozen=True)
class IconFlyweight:
    """ 共享的內蘊狀態物件,確保真正不可變 """
    sprite: bytes
    style: Mapping[str, str]  # 使用 Mapping 確保 style 字典不可變

    def draw(self, context, params: RenderParams):
        """ 繪製方法,接收外蘊狀態包 """
        # 純 ASCII 日誌更穩定
        print(f"Drawing {self.style['shape']} at ({params.x}, {params.y}) with color {params.color}, rotation {params.rotation} deg")

class FlyweightFactory:
    """ 負責創建和管理共享物件的工廠 (執行緒安全) """
    def __init__(self):
        self._pool = {}
        self._lock = threading.Lock()

    def get(self, key: str) -> IconFlyweight:
        """ 根據 key 獲取共享物件,使用雙重檢查鎖定確保執行緒安全 """
        inst = self._pool.get(key)
        if inst is not None:
            return inst

        with self._lock:
            inst = self._pool.get(key) # 再次檢查,防止在等待鎖時其他執行緒已建立
            if inst is None:
                sprite, style_dict = load_asset(key)
                # 使用 MappingProxyType 包裝字典,使其成為唯讀
                inst = IconFlyweight(sprite, MappingProxyType(style_dict))
                self._pool[key] = inst
            return inst

    @property
    def pool_size(self):
        return len(self._pool)

# --- Client 端的使用方式 ---
factory = FlyweightFactory()
points_of_interest = [
    {'x': 10, 'y': 20, 'color': '#FF0000', 'icon_key': 'toilet/default'},
    {'x': 30, 'y': 40, 'color': '#00FF00', 'icon_key': 'stage/main'},
    {'x': 50, 'y': 60, 'color': '#0000FF', 'icon_key': 'toilet/default'},
]
mock_drawing_context = None

for poi in points_of_interest:
    flyweight_icon = factory.get(poi['icon_key'])
    render_params = RenderParams(x=poi['x'], y=poi['y'], color=poi['color'])
    flyweight_icon.draw(mock_drawing_context, render_params)

print(f"\n--- Verification ---")
print(f"Flyweight pool size: {factory.pool_size}")
print(f"Asset load counts: {load_counter.most_common()}")
# assert load_counter['toilet/default'] == 1
# assert load_counter['stage/main'] == 1

反模式紅旗 🚩

在使用 Flyweight 時,要小心避開這些常見的坑,否則省下的記憶體會以另一種混亂的形式討回來:

  • 🚩 污染共享體:試圖把座標、顏色這類外蘊狀態塞回 Flyweight 物件內部。這會讓共享體退化成一個普通的多狀態實例,完全違背了共享的初衷。

  • 🚩 假工廠真浪費:工廠的 get(key) 方法每次都 new 一個新物件,而不是從池中返回已有的實例。這等於完全沒做 Flyweight。

  • 🚩 共享體可變:如果 Flyweight 物件的內蘊狀態(例如 style 字典)是可變的,下游某個使用者修改了它,會導致全城所有使用該共享體的圖示瞬間「變色」,引發難以追蹤的 bug。這就是為什麼範例中要用 MappingProxyType 來保護它。

  • 🚩 外蘊狀態碎片化:如果繪製一個圖示需要的外蘊狀態過於分散,呼叫點需要東拼西湊才能湊齊所有參數,這可能表示這個場景的狀態劃分不清晰,或許根本不適合使用 Flyweight 模式。可以考慮用 Value Object 把外蘊狀態打包。

  • 🚩 在共享體內部執行 I/O:在 draw() 方法中才去讀取圖檔或網路資源,會讓渲染效能變得極不穩定且難以預測。昂貴的 I/O 操作應該嚴格限制在工廠的創建階段。

城市建築師的實務筆記

Key 的設計與正規化

「同義不同鍵」是快取系統的常見殺手。為避免 toilet/defaultToilet/default/ 被當成兩個不同的資產重複載入,應建立 Key 的正規化規範:

  • 大小寫一致:統一轉為小寫。

  • 分隔符一致:統一使用 /

  • 去除噪聲:去除頭尾的空白或斜線。

  • 版本控制:若資產會改版,可考慮加入版本號,如 toilet/default@v2

  • 組合鍵排序:若 key 由多個部分組成,應約定固定順序。

工廠快取的成長與回收策略

目前的工廠 _pool 只會增加,不會減少。對於需要長期運行的服務,如果 key 的種類持續增加,最終仍可能耗盡記憶體。實務上有幾種策略:

  • 常駐 (In-Memory):適用於 key 種類固定且數量可控的小型專案,實作最簡單。

  • 弱引用 (Weak References):使用 weakref.WeakValueDictionary 作為 _pool。當一個 Flyweight 物件在程式其他地方都不再被引用時,GC 就會自動將其從池中回收。

  • LRU (Least Recently Used):為快取池設定一個固定大小上限。當池滿時,最久未被使用的物件會被剔除。這需要搭配命中率統計來調整大小。

批次渲染與材質圖集

當地圖上有成千上萬個圖示時,逐一呼叫 draw() 的開銷相當可觀。實務上可進一步優化:

  • 批次繪製 (Batch Drawing):提供一個 draw_batch(list_of_render_params) 的介面,將同一個 Flyweight 物件的多個繪製請求打包一次性提交,大幅降低 Python 呼叫的成本。

  • 材質圖集 (Texture Atlas):在 GPU 渲染層面,將多個小圖示(例如所有廁所、舞台、攤販的圖)合併成一張大圖。這樣 GPU 可以在一次繪製指令中渲染所有相同圖集的圖示,只需切換座標和顏色,極大減少渲染狀態的切換成本。

✅ 回到現場

讓我們回到 Ken 和 Andy 的辦公室。套用 Flyweight 模式並加上實務優化後,奇蹟發生了:

  • 同樣的 80 萬個地標,同樣豐富的圖示,但 factory.pool_size 的數量只等於地圖上圖示的總種類數(例如 50 種),而不是 80 萬。

  • 記憶體佔用有了清晰的前後對比

    • Before800,000 × 1.2KB ≈ 0.94GB

    • After種類數(≈50) × 1.2KB + 800,000 × (外蘊狀態幾十位元組開銷),記憶體佔用降低了幾個數量級。

  • 地圖的捲動和縮放變得如絲般順滑。

驗收腳本 (要點)

  • 單例驗證:連續呼叫 factory.get('toilet/default') 兩次,驗證返回的是同一個 Python 物件實例(id() 相同)。

  • 外蘊隔離:修改某一個地標的顏色(外蘊狀態),驗證它不會影響到其他使用相同圖示的地標渲染結果。

  • 內蘊不可變:嘗試修改共享物件的內部樣式(例如 flyweight.style['border'] = '#NEW'),程式會因 MappingProxyType 的保護而拋出 TypeError

  • 併發安全與載入觀測:在兩個執行緒中同時呼叫 factory.get('new_icon/unique'),驗證 load_asset 只被呼叫一次 (load_counter['new_icon/unique'] == 1),且最終池的大小只增加 1。

鄉民出題 (動手+反模式紅旗)

  1. 設計題:市府決定,活動舞台的圖示要更有層次感,拆分成兩層共享:「舞台底座 stage_base」(固定不變)和「活動編號徽章 badge」(每天更換)。請你畫出 key 的設計(例如 stage_base/rock_fest;badge/day_1),並描述 draw 方法的介面,它應該如何接收外蘊狀態來組合這兩層 Flyweight?

  2. 實作題:為我們的 FlyweightFactory 加入簡單的統計介面,讓管理者可以查詢快取池的 hits (命中次數)、misses (未命中次數),以及被請求次數 top-N 的 keys。這有助於分析哪些資產是熱點。

  3. 小投票:如果地圖上的圖示種類從 20 種暴增到 2000 種,記憶體壓力再次出現。你傾向於:

    • A. 持續使用 Flyweight,但將 sprite 的實際資料下放到一個更專業的「內容可位址儲存」(Content-Addressable Storage),Flyweight 物件只持有 key。

    • B. 尋求設計上的簡化,例如改採「向量字型」或「符號字形 (Symbol Font)」來繪製圖示,從根本上減少圖示的種類數。

    請在留言中選擇 A 或 B,並附上一句你的理由!

二十字摘要 & 明日預告

摘要:共享不變,外部注入變動;一張圖示,千萬位置也不怕爆記憶體。

預告:明天,我們將在資產的大門口加一道警衛。進入 Day 13|Proxy——透過「代理人」實現門禁、延遲載入、或扮演遠端替身,為我們的城市資產守住第一道邊界!


12. 附錄:ASCII 版圖示

為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:

12.1 微觀層級:類別關係圖

                    ┌─────────────────────┐
                    │   IconFlyweight     │
                    ├─────────────────────┤
                    │ +sprite: bytes      │
                    │ +style: Mapping     │
                    │ +draw(ctx, params)  │
                    └──────────┬──────────┘
                               │
                               │ creates/shares
                               │
    ┌──────────────────┐       ▼
    │     Client       │  ┌─────────────────────┐
    ├──────────────────┤  │  FlyweightFactory   │
    │ +render(poi)     │  ├─────────────────────┤
    └─────┬────────────┘  │ -pool: Map<K,V>     │
          │               │ -lock: Lock         │
          │ get(key)      │ +get(key): Icon     │
          └──────────────▶└─────────────────────┘
          │
          │ uses     ┌─────────────────────┐
          └─────────▶│   RenderParams      │
                     ├─────────────────────┤
                     │ +x: float           │
                     │ +y: float           │
                     │ +color: string      │
                     │ +rotation: int      │
                     └─────────────────────┘

12.2 中觀層級:訊息序列圖

MapApp(Client)    AssetRegistry    RenderService
      │                 │               │
      │ get("toilet")   │               │
      ├────────────────▶│               │
      │                 │               │
      │ ◀─ flyweight ───┤               │
      │                 │               │
      │ draw(flyweight, {x:121,y:25,color:"#f00"})
      ├────────────────────────────────▶│
      │                 │               │
      │ ◀────────── ack ────────────────┤
      │                 │               │

12.3 宏觀層級:角色協作圖

          ┌──────────────┐
          │   Map App    │
          │   (Client)   │
          └──────┬───────┘
                 │ draw(x,y,color,key)
                 ▼
          ┌──────────────┐
          │ RenderAgent  │◀────┐
          └──────┬───────┘     │
                 │ get(key)    │ 登錄能力
                 ▼             │
         ┌───────────────┐     │    ┌──────────────┐
         │ AssetRegistry │◀────┼────│ Directory    │
         │  (Factory)    │     │    │ Facilitator  │
         └───────┬───────┘     │    └──────────────┘
                 │ return      │
                 │ Flyweight   │
                 ▼             │
           ┌───────────┐       │
           │  Canvas   │       │
           │  (Render) │       │
           └───────────┘       │
                               │
                        ┌──────┴──────┐
                        │ 🏛️ 城市建築師 │
                        │   總覽台     │
                        └─────────────┘

12.4 記憶體使用對比圖

記憶體使用量 (MB)
    1000 ┌─ ❌ Before: 每個標記都複製圖示
         │  ████████████████████████████████ 960MB
         │
     500 ┤
         │
         │
       0 └─ ✅ After: 共享圖示 + 外蘊狀態
           ▌ 0.06MB (50種圖示 × 1.2KB)

    圖示數量: 800,000 個地標標記
    圖示種類: 50 種不同圖示
    節省比例: 99.99%

12.5 Flyweight 工廠池運作示意圖

    ┌─────────────────────────────────────┐
    │        FlyweightFactory Pool        │
    ├─────────────────────────────────────┤
    │ "toilet/default"  ──▶ 🚽 [IconFW]   │ ◀┐
    │ "stage/main"      ──▶ 🎵 [IconFW]    │  │
    │ "food/noodle"     ──▶ 🍜 [IconFW]   │  │ 共享實例
    │ "parking/car"     ──▶ 🅿️ [IconFW]   │  │ (內蘊狀態)
    │        ...              ...         │ ◀┘
    └─────────────────────────────────────┘
              ▲                    │
              │ get(key)           │ draw(ctx, params)
              │                    ▼
    ┌─────────────────┐    ┌─────────────────┐
    │   800,000 個    │    │  RenderParams   │
    │   地標請求       │    │  ─────────────  │
    │                 │    │  x: 121.5       │
    │  🚽(10,20)      │    │  y: 25.0        │ 外蘊狀態
    │  🚽(30,40)      │    │  color: "#f00"  │ (變動參數)
    │  🚽(50,60)      │    │  rotation: 0°   │
    │   ...           │    └─────────────────┘
    └─────────────────┘

12.6 執行緒安全的雙重檢查鎖定

Thread A                Thread B
   │                      │
   │ get("icon_x")        │ get("icon_x")
   ▼                      ▼
[檢查池] ──┐               [檢查池] ──┐
   │      │ null            │       │ null
   ▼      │                 ▼       │
[等待鎖] ──┘               [等待鎖] ──┘
   │                         │
   │ 🔐 獲得鎖                │ ⏳ 等待中...
   ▼                         │
[再檢查] ── null              │
   │                         │
   ▼                         │
[載入資產] ── load_asset()     │
   │                         │
   ▼                         │
[存入池] ── pool["x"] = fw    │
   │                         │
   ▼                         │
[釋放鎖] ── 🔓                │
   │                         ▼
   ▼                      🔐 獲得鎖
[回傳 fw]                     │
                             ▼
                          [再檢查] ── found!
                             │
                             ▼
                         [回傳 fw]

12.7 城市地圖渲染效果示意

    Codetopia City Map 2.0 - 即時活動地圖
    ╔═══════════════════════════════════════════╗
    ║  🎵 Music Festival    🍜 Night Market     ║
    ║     @ (45,78)          @ (120,45)        ║
    ║                                          ║
    ║  🚽 Restroom         🅿️ Parking          ║
    ║     @ (89,23)          @ (200,67)        ║
    ║                                          ║
    ║              🏃 Marathon Route           ║
    ║  🚽 ─────── 🏥 ─────── 🚽 ─────── 🎯    ║
    ║ (12,45)   (56,45)   (89,45)   (134,45)   ║
    ║                                          ║
    ║  總共 800,000 個地標                      ║
    ║  記憶體使用: 僅 0.06MB (vs 960MB)         ║
    ╚═══════════════════════════════════════════╝

    圖例:
    🚽 = toilet/default (共用實例)
    🎵 = stage/main (共用實例)
    🍜 = food/noodle (共用實例)
    🅿️ = parking/car (共用實例)
    🏥 = medical/station (共用實例)
    🎯 = checkpoint/finish (共用實例)

上一篇
Day 11:市民服務「一道門」搞定!Facade 模式的簡潔藝術
下一篇
Day 13:Proxy:資產大門的警衛——該放行?該攔截?還是遠端代打?
系列文
Codetopia 新手日記:設計模式與原則的 30 天學習之旅13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言