Flyweight 享元模式 | 實現與解說 | Android Framework Message

Flyweight 享元模式 | 實現與解說 | Android Framework Message

Overview of Content

我們首先了解 Flyweight 設計的本質及其實際應用並查看 Flyweight 設計的含意、Flyweight UML(統一建模語言),讓我們對該模式的結構和目的有了基本的了解

接下來,我們探討 Flyweight 的設計,檢視其固有的優點和缺點。 透過剖析其錯綜複雜的情況,我們可以深入了解享元在何時使用最佳,以及需要謹慎對待的時候

最後再透過探討線程(執行序)安全性和外部狀態管理的問題

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀


Flyweight 設計使用場景

需要 緩衝池 的場景存在大量相似 物件

● 細粒度的物件都具備相近的外部狀態,而內部狀態與環境無關(物件沒有特定身份,可以相互替換,也就是這個物件更像是「值語意」物件)

● 使用共享對象有效的減少類的創建,增加物件的覆用率

Flyweight 定義 & Flyweight UML

Flyweight 定義:使用共享物件去支援,大量的細粒度物件

細粒度物件:物件的每個成員可以區分為 內部狀態 (intrinsic)外部狀態 (extrinsic)

內部狀態 (intrinsic):代表它是一個物件中的 可共享的成員

儲存在 享元對象 內部並且不會隨著環境改變而改變

外部狀態 (extrinsic):代表它是一個物件中的 不可共享的成員,用來當作對象的唯一標誌(不可變更修改)

Flyweight UML 物件角色

物件角色說明
Flyweight(抽象)定義產品的可共享(內部狀態)、不可共享(外部狀態)的實現或接口
ConcreteFlyweight具體享元(產品)角色;可共享的成員處理須與環境無間,不應該出現一個操作改變可共享成員,又同時修改不可共享成員
UnsharedConcreteFlyweight不可共享的享元(產品)角色;不存在外部狀態、安全需求導致無法共享的角色
FlyweightFactory內部創建一個池容器(Pool),用來儲存 Flyweight 對象

Flyweight 設計優缺點

Flyweight 設計優點

● 節省類的創建,也加快速度 (Java 創建類十分耗資源)

● 降低內存消耗、減少 JVM GC 回收導致的 Stop the world 造成的卡頓感

Flyweight 設計缺點

● 提高系統的複雜性(要分離出外部狀態、內部狀態),請判斷在必要的時候再使用,否則可能造成設計過度的問題!


Flyweight 實現

Flyweight 標準

A. Flyweight 抽象類:持有不可修改的外部對象、可修改的內部對象

// 建構函數有不可修改的外對象
abstract class Flyweight constructor(val extrinsic: String) {
    
    // 可修改的內部對象
    var intrinsic : Any? = null
    
    abstract fun operation()

}

B. ConcreteFlyweight:實做 Flyweight 抽象類、其操作函數

// 實做 Flyweight
class ConcreteFlyweight constructor(extrinsic: String): 
    Flyweight(extrinsic) 

    override fun operation() {
        println("Show intrinsic date: $intrinsic")
    }

}

C. FlyweightFactory:享元工廠,內部持有可覆用的 Flyweight 對象,透過外部特徵(不可修改的成員)儲存 Flyweight 對象

可以透過外部特徵(狀態)去判斷該物件是否共享

object FlyweightFactory {

    // 儲存共享物件
    private val pool = HashMap<String, Flyweight>()

    fun getFlyweightObject(extrinsic: String) : Flyweight {
        if (!pool.containsKey(extrinsic)) {
            pool[extrinsic] = ConcreteFlyweight(extrinsic)
        }

        return pool.getValue(extrinsic)
    }

}

● 使用享元設計:

fun main() {
    FlyweightFactory.run {
        
        // "Hello" 代表了外部狀態
        val instance1 = getFlyweightObject("Hello")
        val instance2 = getFlyweightObject("Hello")

        println("instance1 == instance2? ${instance1 == instance2}")

    }
}


Flyweight 模式的考量

Thread 安全性

● 我們知道 Java、Kotlin 天生是多線程(多執行序)程式,由於多線程的問題,我們在使用多線程訪問同一個函數創建對象並設定成員總會造成 不安全設定

● 基於這個問題,我們在創建對象時要使用 Lock) 才安全;以下提出幾個我們在做併發程式時會使用到的多線程(多執行序)鎖,它可以保證線程操作安全性

A. 使用 @Synchronized 註解同步方法

object FlyweightFactory {

    private val pool = HashMap<String, Flyweight>()

    @Synchronized
    fun getFlyweightObject(extrinsic: String) : Flyweight {

        if (!pool.containsKey(extrinsic)) {
            pool[extrinsic] = ConcreteFlyweight(extrinsic)
        }

        return pool.getValue(extrinsic)
    }

}

B. 使用 ReentrantLock 同步方法

object FlyweightFactory {

    private val pool = HashMap<String, Flyweight>()

    private val lock = ReentrantLock()

    fun getFlyweightObject(extrinsic: String) : Flyweight {
        lock.lock()

        if (!pool.containsKey(extrinsic)) {
            pool[extrinsic] = ConcreteFlyweight(extrinsic)
        }

        lock.unlock()
        return pool.getValue(extrinsic)
    }

}

外部狀態:數量 & 性能

物件數量:在使用享元模式時,物件池 (Pool) 中的享元物件不能太少,要到足夠滿足業務為止!

外部狀態

控制享元物件的創建重點是在 外部狀態,外部狀態過少過於簡單會導致物件的創建數量便少,從而更容易產生 Thread 問題

外部狀態控制了內部的物件數量

內部狀態

內部狀態是被管理的物件的特徵;與物件本身較有關係

性能:外部狀態要使用 Java 內建還是自己創建?都可以~ 但依照性能來講建議還是使用 Java/Kotlin 內建物件(像是一些基礎類別)

如果要自己創建物件請務必複寫以下函數,這些函數主要是會影響到外部狀態的「比對」

A. equals 方法(影響 Map#Contains(Object)

B. hashCode 方法(影響 Map#get(Object)

class SelfObject(private val extrinsic1: String, private val extrinsic2: String) {

    var intrinsic : Any? = null
    override fun hashCode(): Int {
        return extrinsic1.hashCode() + extrinsic2.hashCode()
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is SelfObject) return false

        return other.extrinsic1.equals(extrinsic1) && 
                other.extrinsic2.equals(extrinsic2)
    }
}

Android source Message 覆用

我們知道 Android 的 UI 事件是仰賴於「事件驅動模型」,它的設計概念是透過事件(可以是使用者發出,或是程式邏輯發出)來驅動 UI 給予不同的反應;

而物件導向程式,在這種設計之下… 就會導致一個問題點的產生,就是「事件的這個物件過多」的問題… 如果沒有正視考慮到這個問題,那就很容易導致 OOM 的發生

常見的用法如下…


// 範例

void handlerSendMsgToUpdateUI() {
    Handler handler = new Handler(Looper.getMainLooper());

    Message message = Message.obtain(handler, () -> {
        // 更新 UI
    });

    handler.sendMessage(message);
}

● 以下分析的是 Android 10 的 Message.java

Message 覆用機制 obtain:在池中獲取 Message

● 當然,Android 框架的設計者在決定使用「驅動模型」時就有考慮到物件過多的問題,所以它也有設計置一個 Flyweight 享元模式 的設計,讓事件的物件可以循環被系統覆用

● Android 的「事件」就是由 Message 類來表達,它是一個值物件類型,對應我們前面所說的,它具有 內部狀態 的特性;在這個小節中,我們主要關注「從池中取值」的行為

Message 的覆用(從池中取值)入口函數為 obtain,接下來我們就從這裡開始分析


// Message.java

public static final Object sPoolSync = new Object();
private static Message sPool;
private static int sPoolSize = 0;

public static Message obtain() {
    synchronized (sPoolSync) {
        if (sPool != null) {
            Message m = sPool;
            sPool = m.next;
            m.next = null;
            m.flags = 0; // clear in-use flag
            sPoolSize--;
            return m;
        }
    }
    return new Message();
}

從以上程式,我們來抓取 Message 設計時的幾個重點

A. 同步鎖 sPoolSync 物件:Message 類中使用一個 靜態的 Object 物件 作為同步鎖的 Key,它表達的是對於 Pool 的執行序安全操作


// Message.java

public static final Object sPoolSync = new Object();

B. 物件池 sPool 物件:在上面的享元模式範例中,我們使用 Map 作為享元模式的池(用來保存已經使用過得物件),而 Android Message 的設計則更為特別

我們可以看到 sPool 物件的類型是 Message,並且在操作時可以透過 Message#next 成員切換靜態的 sPool 物件,從這裡我們可以看出它是使用了 責任鏈 Chain 的設計 來表達池,將一系列的 Message 串接起來作為「池」


// Message.java

public static final Object sPoolSync = new Object();
private static Message sPool;
private static int sPoolSize = 0;

public static Message obtain() {
    synchronized (sPoolSync) {
        if (sPool != null) {
            // 將原先的指向暫存
            Message m = sPool;
            // 並將 sPool 賦予到原先 Message 的「前方」,也就是新 Message 會作為 Link Header
            sPool = m.next;        
            m.next = null;
            m.flags = 0; // clear in-use flag
            sPoolSize--;
            return m;
        }
    }
    return new Message();
}

graph LR
subgraph chain as pool
m1(新 Message)
m2(原先 Message)
m3(Message)
end
m1 --> |next| m2 --> |next| m3
subgraph Message object
sPool -.-> |當前指向| m1
end

sPool 靜態成員是在哪裡被賦予值的呢?

這個我們等等會看到,它是在 recycleUnchecked 方法中被賦予值的

Message 覆用機制 recycle:把 Message 放入池中

● 在這個小節中,我們主要關注「把物件放入池中」的行為,目的是為了要保存值物件(Message 類)的實例,來避免 JVM 重複創建物件

Message 類,把物件放入池中的方法是 recycle

使用該函數時有個條件,就是當 Messsage 物件已經呼叫過 recycle 代表它已經被放入池中,處於一個可使用的狀態(FLAG_IN_USE),不可以重複呼叫 recycle 否則會拋出 IllegalStateException 異常


// Message.java

public void recycle() {
    if (isInUse()) {
        if (gCheckRecycle) {
            throw new IllegalStateException("This message cannot be recycled because it "
                    + "is still in use.");
        }
        return;
    }
    recycleUnchecked();
}

@UnsupportedAppUsage
void recycleUnchecked() {
    flags = FLAG_IN_USE;        // 設定該物件是可用狀態
    
    // 清除物件內的共享資訊
    what = 0;
    arg1 = 0;
    arg2 = 0;
    obj = null;
    replyTo = null;
    sendingUid = UID_NONE;
    workSourceUid = UID_NONE;
    when = 0;
    target = null;
    callback = null;
    data = null;

    synchronized (sPoolSync) {
        if (sPoolSize < MAX_POOL_SIZE) {
            next = sPool;
            sPool = this;
            sPoolSize++;
        }
    }
}

我們同樣還看一下 recycleUnchecked 函數的重點:

A. 同步鎖 sPoolSync 物件:與上個小節介紹的相同,它表達的是對於 Pool 的執行序安全操作

但這裡我們在特別強調它(鎖)的一個特性,它是「靜態」物件,代表了所有的 Message 實體共享這把鎖,也就是說在 把「Message 放入池中」的行為是安全,但也消耗一定效能的


// Message.java

public static final Object sPoolSync = new Object();

B. 池大小的控制 MAX_POOL_SIZE

如果我們不對池中物件的數量做管控,那相當於無限制的物件(會有記憶體遺漏),仍會導致 OOM,所以 Android Message 有設定可回收的池的數量,而這個數量上限就是 50 個


// Message.java

private static final int MAX_POOL_SIZE = 50;

void recycleUnchecked() {
    ... 省略部份

    synchronized (sPoolSync) {
        if (sPoolSize < MAX_POOL_SIZE) {
            ...
        }
    }
}

C. 將物件放入池(鏈表)中

Message 類中有一個 next 成員,用來指向下一個 Message 物件(從這裡也可以很清楚的看到這是個鏈表設計),所以在回收 Message 物件時會有兩個行為,1. 它會將當前 sPool 指向的物件作為下一個物件,2. 再將自身(this)設定為當前物件 sPool


// Message.java

/*package*/ Message next;

void recycleUnchecked() {
    ... 省略部份

    synchronized (sPoolSync) {
        if (sPoolSize < MAX_POOL_SIZE) {
            // 1. 它會將當前指向的物件作為下一個物件
            next = sPool;
            
            // 2. 將自身(`this`)設定為當前物件 `sPool`
            sPool = this;
            sPoolSize++;
        }
    }
}

總結 Android Message 如何規劃 Flyweight 角色

Flyweight UML 物件角色對應 Android Message 的設計 如下表

可以看到 Android 將 Flyweight UML 物件角色幾乎完全融入到一個 Message 中,這是由於它用了「靜態成員」、「鏈表操作池」的原因!

物件角色說明Android Message 哪個類所承擔
Flyweight(抽象)定義產品的可共享(內部狀態)、不可共享(外部狀態)的實現或接口Message
ConcreteFlyweight具體享元(產品)角色;可共享的成員處理須與環境無間,不應該出現一個操作改變可共享成員,又同時修改不可共享成員Message
UnsharedConcreteFlyweight不可共享的享元(產品)角色;不存在外部狀態、安全需求導致無法共享的角色由於使用「鏈表」操作,所以不用外部狀態
FlyweightFactory內部創建一個池容器,用來除存 Flyweight 對象Message

更多的物件導向設計

物件導向的設計基礎如下,如果是初學者或是不熟悉的各位,建議可以從這些基礎開始認識,打好基底才能走個更穩(在學習的時候也需要不斷回頭看)!

設計建模 2 大概念- UML 分類、使用

物件導向設計原則 – 6 大原則(一)

物件導向設計原則 – 6 大原則(二)

創建、行為、結構型設計 8 個比較 | 包裝模式 | 最佳實踐

創建模式 Creation Patterns

創建模式 PK

創建模式 - Creation Patterns

結構模式 Structural Patterns

結構模式 PK

結構模式 - Structural Patterns

結構模式專注於「物件之間的組成」,以形成更大的結構。這些模式可以幫助你確保當系統進行擴展或修改時,不會破壞其整體結構。例如,外觀模式、代理模式… 等等,詳細解說請點擊以下連結

Bridge 橋接模式 | 解說實現 | 物件導向設計

Decorate 裝飾模式 | 解說實現 | 物件導向設計

Proxy 代理模式 | 解說實現 | 分析動態代理

Iterator 迭代設計 | 解說實現 | 物件導向設計

Facade 外觀、門面模式 | 解說實現 | 物件導向設計

Adapter 設計模式 | 解說實現 | 物件導向設計

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響