MonoGame-DX 多线程多窗体 键盘输入bug

bug产生

这个坑特别的有趣...这是发生在wcR2从xna3.1移植到MonoGame3.4上出现的一个微妙的bug。这是一个游戏场景仿真的程序,我使用了类似如下代码,使得在Winform中可以同时打开多个Game窗体:

void btn_Click() {
  new Thread(()=> {
    new MyGame(args).Run();
  }).Start();
}

其中MyGame继承于Xna的Game类,Run()可以阻塞执行游戏的u/d循环,每个游戏窗体都能正常的捕获输入,这很OK。

然而移植到了MonoGame就发生了灾难,同样的代码打开第一个MyGame窗体时一切正常,而打开了第二个窗体就无法正常捕获键盘输入,Keyboard.GetState()将不会返回任何按键的输入状态,即使关闭了第一个MyGame,或是关闭了所有的MyGame重新打开,依然所有的键盘输入无效。

bug分析

我创建了一个极简的测试用例来还原这个场景,代码如下:

static void Main() {
  Form f = new Form();
  f.MouseClick += (o,e) => {
    new Thread(()=>new Game1().Run()).Start();
  };
  Application.Run(f);
}

class Game1 : Game {
  GraphicsDeviceManager graphics;
  public Game1() {
    graphics = new GraphicsDeviceManager(this);
  }
  protected override Draw(GameTime gameTime) {
    GraphicsDevice.Clear(Keyboard.GetState().IsKeyDown(Keys.A) && IsActive?
      Color.Black : Color.CornflowerBlue);
  }
}

程序入口点创建了一个窗体,当我点击窗体的时候会弹出一个新的游戏窗体,在游戏窗体中按A键会使背景清空为黑色,否则显示为默认的天蓝色。

显然 如果不做任何处理,这段代码在MonoGame-WinDX中只有第一次打开的窗体可以正常响应输入。当然,使用WinGL的话会直接跳出一个多线程相关的错误。另外,这段代码在LinqPad中执行是完全无效,连第一个窗体都无法正常接受输入。

如果我们对主函数换一种写法:

static void Main() {
  new Game1().Run();
  new Game1().Run();
}

这样虽然两个游戏窗体是先后弹出(因为Run()的阻塞执行),但是前后两个窗体的键盘输入都会正确的响应。所以显然,在线程中创建Game是引发Bug的必要条件,并非第二个Game总会出现问题。

但是为什么会这样呢?只能从MonoGame的源代码下手。

我们先看看Keyboard.GetState()的实现,在MonoGame4.5.1版本后可能做了增强,原来的版本只有一句话:

static List _keys;

public static KeyboardState GetState() {
  return new KeyboardState(_keys);
}

而这个静态的_keys是什么时候赋值的呢?祭出反编译神器.net Reflector分析,它大致是这样来的:(参考代码)

class WinFormsGamePlatform : GamePlatform {
  private WinFormsGameWindow _window;
  private readonly List _keyState;

  public WinFormsGamePlatform(Game game) {
    _keyState = new List();
    Keyboard.SetKeys(_keyState);
    _window = new WinFormsGameWindow(this);
    _window.KeyState = _keyState;
    //......
  }
}

而这个类是在Game的构造函数中执行的,也就是说,每创建一个GameKeyboard._keys都会被刷新覆盖一次,并传递到GameWindow中,而GameWindow我们相对比较熟悉,它包含了所有的事件处理逻辑:参考代码

internal WinFormsGameWindow(WinFormsGamePlatform platform) {
  //......
  // Use RawInput to capture key events.
  Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard, DeviceFlags.None);
  Device.KeyboardInput += OnRawKeyEvent;
  //......
}

OnRawKeyEvent中实现了填充KeyState的代码。

一句话结论:KeyboardState底层通过SharpDX.RawInput实现。至于如何实现,我们需要去翻阅SharpDX 2.6.x的快照:参考代码

public static void RegisterDevice(UsagePage usagePage, UsageId usageId, DeviceFlags flags) {
  RegisterDevice(usagePage, usageId, flags, IntPtr.Zero);
}

IntPtr.Zero这个参数很重要。在重载中,他会传递到API原语的hwnd参数中,查阅MSDN文档获知,当传递IntPtr.Zero的时候,表示API将会捕获键盘当前焦点。原文参照这里
最后,这个函数会创建一个单例的messageFilter,挂载到Application或是MessageFilterHook内置字典中,filter将会捕获来自rawInput API的消息,传递给Device.HandleMessage()函数,经过分拣后通过Device.KeyboardInput事件发出。

喵喵喵喵喵?我们总结一下这个事件传递顺序:

//事件注册
Game.ctor();
GamePlatform.ctor();
GameWindow.ctor();
RawInput.Device.RegisterDevice();
User32.RegisterRawInputDevices();

//事件返回
IMessageFilter.PreFilterMessage();
RawInput.Device.HandleMessage();
event RawInput.Device.KeyboardInput;
GameWindow.OnRawKeyEvent();
set Keyboard._keys;

//表层逻辑调用 获取当前的键盘输入状态
Keyboard.GetState();

最后的嫌疑就很明确了:调用RegisterDevice的时候没有提供要捕获特定窗体的hWnd,导致在创建第二个Game时和之前的线程上下文不一致,导致默认单例的filter无法获取新窗体的键盘输入。

bug消灭

按照上面的思路,我们在Game.ctor中主动的对指定窗体调用RegisterDevice,并且创建自己的Filter,问题将会解决。修改Game1的代码如下:

using SharpDx.RawInput;
class Game1 : Game {
  GraphicsDeviceManager graphics;
  public Game1() {
    graphics = new GraphicsDeviceManager(this);
    //fix multi-thread keyboardState bug
    Device.RegisterDevice(UsagePage.Generic, UsageId.GenericKeyboard, DeviceFlags.None, Window.Handle, RegisterDeviceOption.NoFiltering);
    SharpDX.Win32.MessageFilterHook.AddMessageFilter(Window.Handle, new RawInputMessageFilter());
  }
}
//直接复制sharpDX的实现
class RawInputMessageFilter : IMessageFilter {
  public virtual bool PreFilterMessage(ref Message m) {
    if (m.Msg == 0xff)
      Device.HandleMessage(m.LParam);
    return false;
  }
}

再次测试,问题解决。

当然这种解决方法毫不优雅,在rawInput上挂接了多个filter可能会影响Keyboard中静态_keys的生成。不过实际表现好像不错。因为DeviceKeyboardInput事件广播效应,无论来自哪个窗体的rawInput消息都会“顺带”的传递到最新的窗体中,如果最新打开的窗体被关闭则会使这个方法失效,直到下一个窗体重新创建覆盖掉_keys才会恢复。

所以还需要进一步的补救方法:不使用默认Keyboard.GetState(),用反射在每个Game实例中自己写一个替代方法,获取当前GameWindow的keys...嗯...反正你都要写InputManager,这也算是举手之劳了。

最好的方法其实是发issue让官方解决啦...[doge][doge][doge]

后日谈

2016.7.14更新

手欠还是测试了一下,即使反射了keyState,当最新的窗体关闭后,事件依然无法传递进来。所以合理的方法应该为在上面的基础上,重写Game.OnActivated方法,每次窗体焦点时重新执行RegisterDevice,才能基本解决。

另外MonoGame中鼠标也要进行独立处理,好在这里有一个公开的静态重载Mouse.GetState(GameWindow),用它替代无参的GetState函数即可正常使用。

你可能感兴趣的:(MonoGame-DX 多线程多窗体 键盘输入bug)