APSchmidt 发表于 2022-11-6 15:23

Unity Windows 模拟器的实现(一)

需求概述

前段时间项目中有个需求需要开多个客户端进行开发与调试。网上搜索了一下,最简单粗暴的方式就是项目工程拷贝到多个文件夹,然后开多个 Unity 编辑器打开这些工程。相对好一点的方案就是通过 mklink 的方式让多个文件夹引用同一份项目文件。参考这个帖子。
但是这些方案都需要开多个 Unity 编辑器,对于机器性能有一定的要求,而且 Unity 编辑器的刷新效率也着实有点惨不忍睹。
那么我们是否可以实现一个 Windows 的模拟器来满足类似的开发调试需求呢?
具体的需求

要使用模拟器来用于开发和调试,那么这个模拟器需要支持的功能包括:

[*]窗口化的显示。
[*]能够多开,可以通过批处理打开多个客户端,并指定每个客户端的位置与大小。
[*]可以查看代码中输出的日志以及错误信息。
[*]最好能够快速的调试代码修改后的效果。
实现方案

Unity 做为跨平台的引擎,本身就支持将项目发布到 Windows 平台。我们使用 Unity 编辑器自带的 Build 功能就可以生成一个初始版的模拟器。那接下来我们就逐一实现各个功能的支持。
窗口化的显示

在 Unity 编辑器中,可以通过 Build 窗口来生成 Windows 平台的可执行程序。为了方便,我们增加一个菜单项来快速生成,这样也便于后续的开发调试。参考代码如下:
/// <summary>
/// Build Windows Simulator
/// </summary>

public static void BuildWindows()
{
    // 禁用最大化(对应选项:->->->->)
    PlayerSettings.allowFullscreenSwitch = false;

    // 显示模式:窗口化(对应选项:->->->->)
    PlayerSettings.fullScreenMode = FullScreenMode.Windowed;

    // 禁止调整窗口大小(对应选项:->->->->)
    PlayerSettings.resizableWindow = false;

    // exe 文件的保存路径
    var savePath = Path.Combine(Application.dataPath, "../Simulator/Windows/Simulator.exe");

    // 使用 Development Build(对应选项:->->->)
    BuildOptions options = BuildOptions.AllowDebugging | BuildOptions.Development;

    // 开始生成 Windows 平台
    BuildPipeline.BuildPlayer(EditorBuildSettings.scenes, savePath, BuildTarget.StandaloneWindows64, options);
}
在 Unity 编辑器中点击此菜单项之后,等待生成完成(生成所需时间与项目大小有关)就可以看到模拟器生成在项目目录下的 Simulator/Windows 文件夹下。双击运行效果如图:


多开的支持

现在有了模拟器,那么通过批处理脚本我们就可以快速的实现模拟器的多开。我们希望通过命令行启动模拟器时传入一些参数来指定模拟器窗口的大小与显示位置。
先来看下批处理怎么写。我们在项目目录下的 tools 文件夹中新建一个launch_simulator.bat 的批处理文件,代码如下:
cd ..\Simulator\Windows
start .\Simulator.exe -screen-height 600 -screen-width 360 -pos 100,100
start .\Simulator.exe -screen-height 600 -screen-width 360 -pos 500,100
start .\Simulator.exe -screen-height 600 -screen-width 360 -pos 900,100各个参数的含义:

[*]使用 start 命令开启客户端,这样批处理就会继续往下执行,否则批处理会暂停执行,直至模拟器进程结束才会继续。
[*]-screen-height 与 -screen-width 用于指定客户端窗口的大小,这两个参数是 Unity 引擎默认支持的,更多的命令行参数可以参考Unity 官网的使用手册。
[*]-pos 是我们需要实现支持的自定义参数,两个数字表示客户端显示在屏幕中的位置(相对于屏幕左上角的偏移),具体的参数解析与处理稍后讲解。
现在我们双击这个批处理文件,就会看到启动了三个客户端,每个客户端都是 600 * 360 的大小,只是这三个客户端叠在了一起(都在屏幕中间)。
下面我们就来实现指定窗口显示位置的功能,主要分为两个部分:获取命令行参数与设置窗口的显示位置。

[*]获取命令行参数
Unity 引擎有提供了 Environment.GetCommandLineArgs() 接口用于获取当前运行的所有命令行参数。我们在启动场景中增加一个空节点,挂载一个 AppDelegate 的脚本组件。脚本中的代码如下:
using UnityEngine;
using System;

namespace Rainbow.Sample.WinSimulator
{
    /// <summary>
    /// AppDelegate
    /// </summary>
    public class AppDelegate : MonoBehaviour
    {
      /// <summary>
      /// Start is called before the first frame update
      /// </summary>
      void Start()
      {
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
            // 当前运行环境是 Windows 模拟器
            string[] args = Environment.GetCommandLineArgs();
            WindowsStyle.InitStyle(args);
#endif
      }
    }
}
这个脚本的功能就是在 Windows 模拟器的运行环境下,获取当前的命令行参数,并调用 WindowsStyle.InitStyle 接口。WindowsStyle 这个类就是包括了后续各项模拟器需求的实现,大家不要疑惑。

[*]设置窗口的显示位置
我们的窗口实际上就是一个 Windows 系统中的可执行程序窗口,那么为了通过代码来控制这个窗口,就需要调用 Windows 系统提供的各种 API。那怎么样在 Unity 项目的 C# 代码中调用这些 API 呢?其实原理很简单,就是使用 extern 的方式在 C# 代码中声明这些 API 接口,然后就可以进行调用了。
先来看实现代码:
using System;
using System.Runtime.InteropServices;
using UnityEngine;
using System.Diagnostics;

namespace Rainbow.Sample.WinSimulator
{
    /// <summary>
    /// 定制 Windows 模拟器的显示
    /// </summary>
    public static class WindowsStyle
    {
      // 设置窗口位置,尺寸
      
      static extern bool SetWindowPos(IntPtr hWnd, int hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);

      
      static extern IntPtr GetTopWindow(IntPtr hWnd);

      
      static extern int GetWindowThreadProcessId(IntPtr hWnd, ref int pid);

      
      static extern IntPtr GetWindow(IntPtr hWnd, uint wCmd);

      
      static extern IntPtr GetParent(IntPtr hWnd);

      // 一些 Windows Api 需要使用的常量
      const uint SWP_SHOWWINDOW = 0x0040;
      const uint GW_HWNDNEXT = 2;

      private static IntPtr GetCurrentWindow()
      {
            var process = Process.GetCurrentProcess();
            var processID = process.Id;

            int dwPID = 0;
            IntPtr hwndRet = IntPtr.Zero;

            // 取得第一个窗口句柄
            IntPtr hwndWindow = GetTopWindow(IntPtr.Zero);
            while (hwndWindow != IntPtr.Zero)
            {
                dwPID = 0;

                // 通过窗口句柄取得进程ID
                int dwThreadID = GetWindowThreadProcessId(hwndWindow, ref dwPID);
                if (dwThreadID != 0 && dwPID == processID)
                {
                  // 进程ID相等,则记录窗口句柄
                  hwndRet = hwndWindow;
                  break;
                }
                // 取得下一个窗口句柄
                hwndWindow = GetWindow(hwndWindow, GW_HWNDNEXT);
            }

            // 上面取得的窗口,不一定是最上层的窗口,需要通过GetParent获取最顶层窗口
            IntPtr hwndWindowParent = IntPtr.Zero;

            // 循环查找父窗口,以便保证返回的句柄是最顶层的窗口句柄
            while (hwndRet != IntPtr.Zero)
            {
                hwndWindowParent = GetParent(hwndRet);
                if (hwndWindowParent == IntPtr.Zero)
                {
                  break;
                }
                hwndRet = hwndWindowParent;
            }

            // 返回窗口句柄
            return hwndRet;
      }

      /// <summary>
      /// 初始化模拟器的样式
      /// </summary>
      /// <param name="args">模拟器的启动参数</param>
      public static void InitStyle(string[] args)
      {
            try
            {
                // 获取当前窗口的句柄
                var hWnd = GetCurrentWindow();

                // 如果有指定窗口位置,进行设置
                var posArgIdx = Array.IndexOf(args, "-pos");
                if (posArgIdx >= 0 && args.Length > (posArgIdx + 1))
                {
                  var posStr = args;
                  var posArr = posStr.Split(',');
                  int posX = int.Parse(posArr);
                  int posY = int.Parse(posArr);
                  SetWindowPos(hWnd, 0, posX, posY, Screen.width, Screen.height, SWP_SHOWWINDOW);
                }
            }
            catch (Exception e)
            {
                UnityEngine.Debug.LogError($"初始化模拟器失败:{e.Message}");
            }
      }
    }
}
代码中有各个部分逻辑的注释,这里补充说明几点:

[*]Windows 系统的 API 大家可以根据需要在网上查找资料,给大家推荐一下这个微软的资料网站。C# 与 Windows API 之间的参数类型转换方面会比较麻烦,也可以在网上查找资料来解决。
[*]我在实现此功能的时候,网上查找到的资料基本都是通过 GetTopWindow 这个 API 接口来获取当前窗口,但是实际测试时发现:如果在启动过程中将焦点转移到其他可执行程序的窗口,那么 GetTopWindow 接口获取的是当前焦点所在窗口,而不是我们的模拟器 。所以为了解决这个问题,实现了 GetCurrentWindow 函数,通过当前的进程 ID 来获取模拟器窗口。这样操作才是精准的。
FAQ

Q:模拟器生成时指定了使用非全屏的配置,但是启动还一直是全屏状态。
A:如果模拟器有使用全屏方式生成并启动过,那么在系统的注册表中会有模拟器的全屏模式配置残留,注册表的路径为:HKEY_CURRENT_USER\Software\\。需要将这些注册表删除:


尾声

关于 Unity Windows 模拟器的实现,今天就先介绍到这里。模拟器需要实现的其他需求下次再继续为大家讲解。到时候,我也会提供文章中使用的 Unity 工程。希望这篇文章能帮助到你,谢谢!
页: [1]
查看完整版本: Unity Windows 模拟器的实现(一)