找回密码
 立即注册
首页 业界区 业界 在C#中使用适配器Adapter模式和扩展方法解决面向对象设 ...

在C#中使用适配器Adapter模式和扩展方法解决面向对象设计问题

孔季雅 2025-6-6 13:50:28
之前有阵子在业余时间拓展自己的一个游戏框架,结果在实现的过程中发现一个设计问题。这个游戏框架基于MonoGame实现,在MonoGame中,所有的材质渲染(Texture Rendering)都是通过SpriteBatch类来完成的。举个例子,假如希望在屏幕的某个地方显示一个图片材质(imageTexture),就在Game类的子类的Draw方法里,使用下面的代码来绘制图片:
  1. protected override void Draw(GameTime gameTime)
  2. {
  3.     // ...
  4.     spriteBatch.Draw(imageTexture, new Vector2(x, y), Color.White);
  5.     // ...
  6. }
复制代码
那么如果希望在屏幕的某个地方用某个字体来显示一个字符串,就类似地调用SpriteBatch的DrawString方法来完成:
  1. protected override void Draw(GameTime gameTime)
  2. {
  3.     // ...
  4.     spriteBatch.DrawString(spriteFont, "Hello World", new Vector2(x, y), Color.White);
  5.     // ...
  6. }
复制代码
暂时可以不用管这两个代码中spriteBatch对象是如何初始化的,以及Draw和DrawString两个方法的各个参数是什么意思,在本文讨论的范围中,只需要关注spriteFont这个对象即可。MonoGame使用一种叫“内容管道”(Content Pipeline)的技术,将各种资源(声音、音乐、字体、材质等等)编译成xnb文件,之后,通过ContentManager类,将这些资源读入内存,并创建相应的对象。SpriteFont就是其中一种资源(字体)对象,在Game的Load方法中,可以通过指定xnb文件名的方式,从ContentManager获取字体信息:
  1. private SpriteFont? spriteFont;
  2. protected override void LoadContent()
  3. {
  4.     // ...
  5.     spriteFont = Content.Load<SpriteFont>("fonts\\arial"); // Load from fonts\\arial.xnb
  6.     // ...
  7. }
复制代码
OK,与MonoGame相关的知识就介绍这么多。接下来,就进入具体问题。由于是做游戏开发框架,那么为了能够更加方便地在屏幕上(确切地说是在当前场景里)显示字符串,我封装了一个Label类,这个类大致如下所示:
  1. public class Label : VisibleComponent
  2. {
  3.     private readonly SpriteFont _spriteFont;
  4.    
  5.     public Label(string text, SpriteFont spriteFont, Vector2 pos, Color color)
  6.     {
  7.         Text = text;
  8.         _spriteFont = spriteFont;
  9.         Position = pos;
  10.         TextColor = color;
  11.     }
  12.     public string Text { get; set; }
  13.     public Vector2 Position { get; set; }
  14.     public Color TextColor { get; set; }
  15.     protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
  16.         => spriteBatch.DrawString(_spriteFont, Text, Position, TextColor);
  17. }
复制代码
这样实现本身并没有什么问题,但是仔细思考不难发现,SpriteFont是从Content Pipeline读入的字体信息,而字体信息不仅包含字体名称,而且还包含字体大小(字号),并且在Pipeline编译的时候就已经确定下来了,所以,如果游戏中希望使用同一个字体的不同字号来显示不同的字符串时,就需要加载多个SpriteFont,不仅麻烦而且耗资源,灵活度也不高。
经过一番搜索,发现有一款开源的字体渲染库:FontStashSharp,它有MonoGame的扩展,可以基于字体的不同字号,动态加载字体对象(称之为“动态精灵字体(DynamicSpriteFont)”),然后使用MonoGame原生的SpriteBatch将字符串以指定的动态字体显示在场景中,比如:
  1. private readonly FontSystem _fontSystem = new();
  2. private DynamicSpriteFont? _menuFont;
  3. public override void Load(ContentManager contentManager)
  4. {
  5.     // Fonts
  6.     _fontSystem.AddFont(File.ReadAllBytes("res/main.ttf"));
  7.     _menuFont = _fontSystem.GetFont(30);
  8. }
  9. public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
  10. {
  11.     spriteBatch.DrawString(_menuFont, "Hello World", new Vector2(100, 100), Color.Red);
  12. }
复制代码
在上面的Draw方法中,仍然是使用了SpriteBatch.DrawString方法来显示字符串,不同的地方是,这个DrawString方法所接受的第一个参数为DynamicSpriteFont对象,这个DynamicSpriteFont对象是第三方库FontStashSharp提供的,它并不是标准的MonoGame里的类型,所以,这里有两种可能:

  • DynamicSpriteFont是MonoGame中SpriteFont的子类
  • FontStashSharp使用了C#扩展方法,对SpriteBatch类型进行了扩展,使得DrawString方法可以使用DynamicSpriteFont来绘制文本
如果是第一种可能,那问题倒也简单,基本上自己开发的这个游戏框架可以不用修改,比如在创建Label实例的时候,构造函数第二个参数直接将DynamicSpriteFont对象传入即可。但不幸的是,这里属于第二种情况,也就是FontStashSharp中的DynamicSpriteFont与SpriteFont之间并没有继承关系。
现在总结一下,目前的现状是:

  • DynamicSpriteFont并不是SpriteFont的子类
  • 两者提供相似的能力:都能够被SpriteBatch用来绘制文本,都能够基于给定的文本字符串来计算绘制区域的宽度和高度(两者都提供MeasureString方法)
  • 我希望在我的游戏框架中能够同时使用SpriteFont和DynamicSpriteFont,也就是说,我希望Label可以同时兼容SpriteFont和DynamicSpriteFont的文本绘制能力
很明显,可以使用GoF95的适配器(Adapter)模式来解决目前的问题,以满足上述3的条件。为此,可以定义一个IFontAdapter接口,然后基于SpriteFont和DynamicSpriteFont来提供两种不同的适配器实现,最后,让框架里的类型(比如Label)依赖于IFontAdapter接口即可,UML类图大致如下:
1.png

DynamicSpriteFontAdapter被实现在一个独立的包(C#中的Assembly)里,这样做的目的是防止Mfx.Core项目对FontStashSharp有直接依赖,因为Mfx.Core作为整个游戏框架的核心组件,会被不同的游戏主体或者其它组件引用,而这些组件并不需要依赖FontStashSharp。
此外,同样可以使用C#的扩展方法特性,让SpriteBatch可以基于IFontAdapter进行文本绘制:
  1. public static class SpriteBatchExtensions
  2. {
  3.     public static void DrawString(
  4.         this SpriteBatch spriteBatch,
  5.         IFontAdapter fontAdapter,
  6.         string text) => fontAdapter.DrawString(spriteBatch, text);
  7. }
复制代码
 其它相关代码类似如下:
  1. public interface IFontAdapter
  2. {
  3.     void DrawString(SpriteBatch spriteBatch, string text);
  4.     Vector2 MeasureString(string text);
  5. }
  6. public sealed class SpriteFontAdapter(SpriteFont spriteFont) : IFontAdapter
  7. {
  8.     public Vector2 MeasureString(string text) => spriteFont.MeasureString(text);
  9.     public void DrawString(SpriteBatch spriteBatch, string text)
  10.         => spriteBatch.DrawString(spriteFont, text);
  11. }
  12. public sealed class FontStashSharpAdapter(DynamicSpriteFont spriteFont) : IFontAdapter
  13. {
  14.     public void DrawString(SpriteBatch spriteBatch, string text)
  15.         => spriteBatch.DrawString(spriteFont, text);
  16.     public Vector2 MeasureString(string text) => spriteFont.MeasureString(text);
  17. }
  18. public class Label(string text, IFontAdapter fontAdapter) : VisibleComponent
  19. {
  20.     // 其它成员忽略
  21.     public string Text { get; set; } = text;
  22.     protected override void ExecuteDraw(GameTime gameTime, SpriteBatch spriteBatch)
  23.         => spriteBatch.DrawString(fontAdapter, Text);
  24. }
复制代码
总结一下:本文通过对一个实际案例的分析,讨论了GoF95设计模式中的Adapter模式在实际项目中的应用,展示了如何使用面向对象设计模式来解决实际问题的方法。Adapter模式的引入也会产生一些边界效应,比如本案例中FontStashSharp的DynamicSpriteFont其实还能够提供更多更为丰富的功能特性,然而Adapter模式的使用,使得这些功能特性不能被自制的游戏框架充分使用(因为接口统一,而标准的SpriteFont并不提供这些功能),一种有效的解决方案是,扩展IFontAdapter接口的职责,然后使用空对象模式来补全某个适配器中不被支持的功能特性,但这种做法又会在框架设计中,让某些类型的层次结构设计变得特殊化,也就是为了迎合某个外部框架而去做抽象,使得设计变得不那么纯粹,所以,还是需要根据实际项目的需求来决定设计的方式。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册