多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

适配器模式:游戏开发的“万能转换头”

适配器模式:游戏开发的“万能转换头” 你买了一副耳机插头是 Type-C但电脑只有 USB-A 接口。怎么办不需要改耳机也不需要拆电脑加一个转换头就行耳机 → 转换头 → 电脑软件开发中也有类似问题你需要某项功能现有代码也提供了这项功能但双方接口对不上。适配器模式Adapter Pattern就是解决这个问题的业务代码 → 适配器 → 现有系统它的核心不是“创造新功能”而是把现有接口转换为业务希望使用的接口让原本不兼容的双方能够协作。一、游戏里为什么特别需要适配器假设你正在开发一款 Unity 游戏需要播放音效。项目原来使用 Unity 的音频系统audioSource.PlayOneShot(clip);后来团队决定接入另一个音频系统它使用事件路径audioSdk.TriggerEvent(event:/SFX/Explosion);再后来测试环境不希望真的播放声音只需要记录Debug.Log(播放音效Explosion);三个系统都能处理“播放音效”但调用方式不同Unity AudioClip → PlayOneShot 第三方音频系统 事件路径 → TriggerEvent 测试环境 音效标识 → 记录日志如果业务代码直接依赖这些具体 APIpublicvoidExplode(){// 这里到处都是某个音频系统的专属调用。}更换音频系统时就可能要修改武器技能UI怪物关卡剧情系统。这就是典型的底层接口的变化扩散到了整个游戏业务层。二、解决思路业务只认识自己的接口先规定游戏需要怎样播放声音publicinterfaceIGameAudio{voidPlay(stringsoundId);}业务代码只认这个接口publicsealedclassWeapon{privatereadonlyIGameAudio_audio;publicWeapon(IGameAudioaudio){_audioaudio;}publicvoidFire(){// 发射子弹……_audio.Play(weapon.fire);}}现在武器不需要知道是不是 AudioSource 是不是第三方 SDK 是否需要事件路径 是否正在测试它只表达播放游戏定义的weapon.fire音效。接下来适配器负责翻译。三、第一种适配把游戏音效 ID 转成 AudioClipUnity 的AudioSource不认识weapon.fire它需要AudioClip。因此适配器需要完成游戏音效 ID ↓ 查表 AudioClip ↓ AudioSource.PlayOneShotusingSystem;usingSystem.Collections.Generic;usingUnityEngine;publicsealedclassUnityAudioAdapter:IGameAudio{privatereadonlyAudioSource_source;privatereadonlyIReadOnlyDictionarystring,AudioClip_clips;publicUnityAudioAdapter(AudioSourcesource,IReadOnlyDictionarystring,AudioClipclips){if(sourcenull)thrownewArgumentNullException(nameof(source));_sourcesource;_clipsclips??thrownewArgumentNullException(nameof(clips));}publicvoidPlay(stringsoundId){if(!_clips.TryGetValue(soundId,outAudioClipclip)||clipnull){Debug.LogWarning($未配置音效{soundId});return;}_source.PlayOneShot(clip);}}这里有两层转换数据转换soundId→AudioClip调用转换Play()→PlayOneShot()。这就是适配器真正做的事情而不只是给方法换个名字。四、第二种适配接入第三方音频系统假设第三方 SDK 提供这样的接口// 示例 SDK不代表某个真实产品的 API。publicsealedclassVendorAudioSdk{publicvoidTriggerEvent(stringeventPath){// 调用音频引擎。}}我们不能或不希望修改 SDK就在外面加一层usingSystem;usingSystem.Collections.Generic;publicsealedclassVendorAudioAdapter:IGameAudio{privatereadonlyVendorAudioSdk_sdk;privatereadonlyIReadOnlyDictionarystring,string_eventPaths;publicVendorAudioAdapter(VendorAudioSdksdk,IReadOnlyDictionarystring,stringeventPaths){_sdksdk??thrownewArgumentNullException(nameof(sdk));_eventPathseventPaths??thrownewArgumentNullException(nameof(eventPaths));}publicvoidPlay(stringsoundId){if(!_eventPaths.TryGetValue(soundId,outstringpath)){thrownewKeyNotFoundException($未配置音频事件{soundId});}_sdk.TriggerEvent(path);}}业务代码保持不变weapon.Fire();只在初始化位置决定使用哪个适配器IGameAudioaudionewVendorAudioAdapter(sdk,eventPaths);WeaponweaponnewWeapon(audio);整个依赖关系变成Weapon │ ▼ IGameAudio ▲ │ 实现 VendorAudioAdapter │ ▼ VendorAudioSdk业务层依赖自己的抽象适配器依赖外部 SDK。上面两个示例采用了不同的缺失配置处理方式正式项目中应统一接口契约究竟记录日志、返回失败还是抛异常不能因后端不同而随意变化。五、适配器模式的四个角色对应刚才的例子角色示例职责Client调用方Weapon使用目标接口Target目标接口IGameAudio定义业务希望如何调用Adaptee被适配者AudioSource、第三方 SDK提供已有功能Adapter适配器UnityAudioAdapter转换接口与数据一句话串起来调用方通过目标接口使用适配器间接访问被适配者。需要注意仅仅“实现了同一个接口”不一定就是适配器模式。比如publicsealedclassSilentAudio:IGameAudio{publicvoidPlay(stringsoundId){// 什么也不做。}}它没有转换另一个现有系统的接口更接近Null Object空对象。判断模式要看它解决的问题而不只是代码长相。六、适配器适配的不只是方法名游戏项目中真正麻烦的往往是“语义不一致”。1. 单位适配游戏使用秒SDK 使用毫秒publicvoidSetTimeout(floatseconds){intmillisecondschecked((int)Math.Ceiling(seconds*1000.0));_sdk.SetTimeoutMilliseconds(milliseconds);}还需要明确是否允许负数如何取整是否可能溢出。2. 坐标适配外部设备和游戏可能使用不同的轴方向原点单位左右手坐标约定。适配器可以统一到游戏坐标系。但要注意位置转换正确不代表旋转也能靠随意翻转一个分量解决。应使用明确的坐标变换关系处理位置、方向和旋转。3. 异步形式适配SDK 使用回调sdk.Login(onSuccess,onFailure);业务希望LoginResultresultawaitaccount.LoginAsync();适配器可以把回调转换为Task。但还要处理回调是否可能多次触发取消和超时异常传播回调线程场景退出后是否仍然有效。把回调包装成 Task不等于底层操作自动支持取消。4. 错误语义适配不同登录 SDK 返回厂商 A错误码 1007 厂商 B字符串 USER_ABORT业务希望统一为LoginStatus.Cancelled适配器可以归一化错误但应保留原始错误信息方便定位问题。七、为什么游戏里通常选择“对象适配器”常见做法是持有被适配对象而不是继承它。publicsealedclassAudioAdapter:IGameAudio{privatereadonlyVendorAudioSdk_sdk;}这叫对象适配器使用组合关系Adapter has an Adaptee 适配器持有被适配者好处包括SDK 即使是sealed也能包装不占用 C# 唯一的类继承位置可以包装现有实例更容易替换依赖和测试适合包装 Unity 的组件对象。例如通常应该持有一个AudioSource引用而不是为了转换音频接口去继承它。适配器本身也不一定要是MonoBehaviour。没有组件生命周期需求时普通 C# 类通常更简单。八、和外观、装饰器、策略模式有什么区别它们都可能有一层“包装”但目的不同。模式主要目的游戏例子适配器 Adapter让不兼容接口对接把第三方支付 SDK 转成游戏支付接口外观 Facade简化复杂子系统的使用StartMatch()内部完成建房、连接、加载装饰器 Decorator在原有接口上叠加能力给音频调用增加日志和限流策略 Strategy替换某种算法或行为切换敌人的追击算法大白话适配器你说的话不一样我来翻译。 外观你操作太复杂我给一个简单入口。 装饰器原来的功能保留我再加点能力。 策略这件事有几种做法换一种执行。一个设计也可能同时具有多个模式的特征不必为了贴标签硬拆类。九、“万能转换头”也有转换不了的东西适配器不能凭空制造底层能力。假设业务接口是publicinterfaceIAudioPlayer{voidPlay(stringid);voidPause(stringid);voidSeek(stringid,floatseconds);}但某个 SDK 只支持一次性播放不支持暂停和跳转。这时不能简单写publicvoidSeek(stringid,floatseconds){// 假装成功实际不处理。}这会破坏接口契约。更合理的选择可能是拆分一次性音效与可控音轨接口显式暴露能力查询返回明确的“不支持”结果换用具备所需能力的底层系统。接口形式可以转换能力缺失不能靠包装消失。十、游戏工程里怎么用才不容易翻车1. 适配层尽量薄适合放参数映射 单位转换 API 转发 错误归一化 必要的生命周期桥接不适合塞进去战斗规则 装备掉落 活动奖励 角色成长否则“转换头”会长成一个新的业务中心。2. 明确资源归谁管理例如音频适配器持有AudioSource谁创建它 谁销毁它 切换场景后是否有效 适配器释放时是否要停掉播放包装了对象不代表自动拥有它。3. 不让 SDK 类型泄漏到业务接口尽量避免VendorLoginResultLogin();更合理的是GameLoginResultLogin();否则业务仍然依赖厂商类型换 SDK 时依旧要大面积修改。4. 不为每个类都加适配器适配器适合放在变化明显的系统边界支付登录广告音频平台服务外部设备遗留系统。如果双方接口本来就一致直接调用即可。总结适配器模式最适合解决“功能已经有了但接口不是我想要的而且我不想修改现有系统。”它通过这一层关系隔离变化稳定的游戏业务 ↓ 游戏自己的接口 ↓ 适配器 ↓ 可能变化的 SDK、平台或遗留系统它真正的价值不是少写几行调用代码而是让外部系统的变化尽量停留在边界不要一路传染到整个游戏。
返回列表