在C#中使用介面卡Adapter模式和擴充套件方法解決物件導向設計問題

dax.net發表於2024-10-07

之前有陣子在業餘時間擴充自己的一個遊戲框架,結果在實現的過程中發現一個設計問題。這個遊戲框架基於MonoGame實現,在MonoGame中,所有的材質渲染(Texture Rendering)都是透過SpriteBatch類來完成的。舉個例子,假如希望在螢幕的某個地方顯示一個圖片材質(imageTexture),就在Game類的子類的Draw方法裡,使用下面的程式碼來繪製圖片:

protected override void Draw(GameTime gameTime)
{
    // ...
    spriteBatch.Draw(imageTexture, new Vector2(x, y), Color.White);
    // ...
}

那麼如果希望在螢幕的某個地方用某個字型來顯示一個字串,就類似地呼叫SpriteBatchDrawString方法來完成:

protected override void Draw(GameTime gameTime)
{
    // ...
    spriteBatch.DrawString(spriteFont, "Hello World", new Vector2(x, y), Color.White);
    // ...
}

暫時可以不用管這兩個程式碼中spriteBatch物件是如何初始化的,以及DrawDrawString兩個方法的各個引數是什麼意思,在本文討論的範圍中,只需要關注spriteFont這個物件即可。MonoGame使用一種叫“內容管道”(Content Pipeline)的技術,將各種資源(聲音、音樂、字型、材質等等)編譯成xnb檔案,之後,透過ContentManager類,將這些資源讀入記憶體,並建立相應的物件。SpriteFont就是其中一種資源(字型)物件,在GameLoad方法中,可以透過指定xnb檔名的方式,從ContentManager獲取字型資訊:

private SpriteFont? spriteFont;
protected override void LoadContent()
{
    // ...
    spriteFont = Content.Load<SpriteFont>("fonts\\arial"); // Load from fonts\\arial.xnb
    // ...
}

OK,與MonoGame相關的知識就介紹這麼多。接下來,就進入具體問題。由於是做遊戲開發框架,那麼為了能夠更加方便地在螢幕上(確切地說是在當前場景裡)顯示字串,我封裝了一個Label類,這個類大致如下所示:

public class Label : VisibleComponent
{
    private readonly SpriteFont _spriteFont;
    
    public Label(string text, SpriteFont spriteFont, Vector2 pos, Color color)
    {
        Text = text;
        _spriteFont = spriteFont;
        Position = pos;
        TextColor = color;
    }

    public string Text { get; set; }
    public Vector2 Position { get; set; }
    public Color TextColor { get; set; }

    protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
        => spriteBatch.DrawString(_spriteFont, Text, Position, TextColor);
}

這樣實現本身並沒有什麼問題,但是仔細思考不難發現,SpriteFont是從Content Pipeline讀入的字型資訊,而字型資訊不僅包含字型名稱,而且還包含字型大小(字號),並且在Pipeline編譯的時候就已經確定下來了,所以,如果遊戲中希望使用同一個字型的不同字號來顯示不同的字串時,就需要載入多個SpriteFont,不僅麻煩而且耗資源,靈活度也不高。

經過一番搜尋,發現有一款開源的字型渲染庫:FontStashSharp,它有MonoGame的擴充套件,可以基於字型的不同字號,動態載入字型物件(稱之為“動態精靈字型(DynamicSpriteFont)”),然後使用MonoGame原生的SpriteBatch將字串以指定的動態字型顯示在場景中,比如:

private readonly FontSystem _fontSystem = new();
private DynamicSpriteFont? _menuFont;

public override void Load(ContentManager contentManager)
{
    // Fonts
    _fontSystem.AddFont(File.ReadAllBytes("res/main.ttf"));
    _menuFont = _fontSystem.GetFont(30);
}

public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
    spriteBatch.DrawString(_menuFont, "Hello World", new Vector2(100, 100), Color.Red);
}

在上面的Draw方法中,仍然是使用了SpriteBatch.DrawString方法來顯示字串,不同的地方是,這個DrawString方法所接受的第一個引數為DynamicSpriteFont物件,這個DynamicSpriteFont物件是第三方庫FontStashSharp提供的,它並不是標準的MonoGame裡的型別,所以,這裡有兩種可能:

  1. DynamicSpriteFont是MonoGame中SpriteFont的子類
  2. FontStashSharp使用了C#擴充套件方法,對SpriteBatch型別進行了擴充套件,使得DrawString方法可以使用DynamicSpriteFont來繪製文字

如果是第一種可能,那問題倒也簡單,基本上自己開發的這個遊戲框架可以不用修改,比如在建立Label例項的時候,建構函式第二個引數直接將DynamicSpriteFont物件傳入即可。但不幸的是,這裡屬於第二種情況,也就是FontStashSharp中的DynamicSpriteFontSpriteFont之間並沒有繼承關係。

現在總結一下,目前的現狀是:

  1. DynamicSpriteFont並不是SpriteFont的子類
  2. 兩者提供相似的能力:都能夠被SpriteBatch用來繪製文字,都能夠基於給定的文字字串來計算繪製區域的寬度和高度(兩者都提供MeasureString方法)
  3. 我希望在我的遊戲框架中能夠同時使用SpriteFontDynamicSpriteFont,也就是說,我希望Label可以同時相容SpriteFontDynamicSpriteFont的文字繪製能力

很明顯,可以使用GoF95的介面卡(Adapter)模式來解決目前的問題,以滿足上述3的條件。為此,可以定義一個IFontAdapter介面,然後基於SpriteFontDynamicSpriteFont來提供兩種不同的介面卡實現,最後,讓框架裡的型別(比如Label)依賴於IFontAdapter介面即可,UML類圖大致如下:

在C#中使用介面卡Adapter模式和擴充套件方法解決物件導向設計問題

DynamicSpriteFontAdapter被實現在一個獨立的包(C#中的Assembly)裡,這樣做的目的是防止Mfx.Core專案對FontStashSharp有直接依賴,因為Mfx.Core作為整個遊戲框架的核心元件,會被不同的遊戲主體或者其它元件引用,而這些元件並不需要依賴FontStashSharp。

此外,同樣可以使用C#的擴充套件方法特性,讓SpriteBatch可以基於IFontAdapter進行文字繪製:

public static class SpriteBatchExtensions
{
    public static void DrawString(
        this SpriteBatch spriteBatch, 
        IFontAdapter fontAdapter, 
        string text) => fontAdapter.DrawString(spriteBatch, text);
}

其它相關程式碼類似如下:

public interface IFontAdapter
{
    void DrawString(SpriteBatch spriteBatch, string text);
    Vector2 MeasureString(string text);
}

public sealed class SpriteFontAdapter(SpriteFont spriteFont) : IFontAdapter
{
    public Vector2 MeasureString(string text) => spriteFont.MeasureString(text);

    public void DrawString(SpriteBatch spriteBatch, string text)
        => spriteBatch.DrawString(spriteFont, text);
}

public sealed class FontStashSharpAdapter(DynamicSpriteFont spriteFont) : IFontAdapter
{
    public void DrawString(SpriteBatch spriteBatch, string text)
        => spriteBatch.DrawString(spriteFont, text);

    public Vector2 MeasureString(string text) => spriteFont.MeasureString(text);
}

public class Label(string text, IFontAdapter fontAdapter) : VisibleComponent
{
    // 其它成員忽略
    public string Text { get; set; } = text;

    protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
        => spriteBatch.DrawString(fontAdapter, Text);
}

總結一下:本文透過對一個實際案例的分析,討論了GoF95設計模式中的Adapter模式在實際專案中的應用,展示瞭如何使用物件導向設計模式來解決實際問題的方法。Adapter模式的引入也會產生一些邊界效應,比如本案例中FontStashSharp的DynamicSpriteFont其實還能夠提供更多更為豐富的功能特性,然而Adapter模式的使用,使得這些功能特性不能被自制的遊戲框架充分使用(因為介面統一,而標準的SpriteFont並不提供這些功能),一種有效的解決方案是,擴充套件IFontAdapter介面的職責,然後使用空物件模式來補全某個介面卡中不被支援的功能特性,但這種做法又會在框架設計中,讓某些型別的層次結構設計變得特殊化,也就是為了迎合某個外部框架而去做抽象,使得設計變得不那麼純粹,所以,還是需要根據實際專案的需求來決定設計的方式。

相關文章