标签归档:Unity Editor

macOS 下 Unity 执行子进程调用时无法读取到系统环境变量问题修复备忘

发现问题

由于我们的游戏工程中集成了多个渠道的 Android 版本的 SDK,而不同的 SDK 依赖的一些库多少有些区别,而且不同的 SDK 通常都有着各种不同的需求,最终我们的处理方式是针对不同的 SDK 做不同的打包预处理,这样就可以在打不同的渠道包的时候,按照不同的 SDK 的需求做相应的处理,确保不同的 SDK 的渠道包里头包含的库文件和资源文件不同。

这样我们就在打包执行先执行了一个 Gradle 的 build 脚本,通过 Gradle 的脚本中不同的任务来完成针对不同 SDK 的 Android 工程的编译以及资源文件的删除和拷贝等等。

但是在我们的 Jenkins 打包服务器上打包出来的 APK 文件安装到手机上,启动游戏之后总是提示丢失了类文件,直接就闪退了,这说明打包的过程中出现了问题。一步步排查,最后发现在打包预处理的 Gradle 脚本执行的时候抛出了异常,异常的提示信息也非常的明确,就是 Gradle 在执行 Android 工程的 build 时缺少 ANDROID_HOME 系统环境变量或者在 Gradle 工程的 local.properties 中缺少了 sdk.dir 的设置项,导致 Gradle 在编译 Android 工程时无法正确地引用 Android SDK 进行编译,最终导致 Unity 打包出来的 APK 中缺少了类最终造成了启动就闪退的问题。

解决方案

那么最简单的方法当然是通过添加 local.properties 文件来解决这个问题,可是我看了一眼 Android Studio 生成的 local.properties 文件的内容:

## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file must *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
#Thu Mar 22 17:12:39 CST 2018
ndk.dir=/Users/helihua/Library/Android/sdk/ndk-bundle
sdk.dir=/Users/helihua/Library/Android/sdk

这货原本就是 Android Studio 创建 Android 工程时或者说是打开工程时根据当前的系统变量自行生成或者设置的,而且明确告知别把这个文件放到 git 等版本管理控制中去,也就是说别把你本地的一个配置给推送到服务器上去,防止别人从服务器上拉取下来你这个本地配置,这样就可能导致团队中其他的小伙伴在编译的过程中出错。

那么我们自然也不能选择这种方式了,而且 Android Studio 这货非常省心地帮你把这个文件都给放到了工程的 .gitignore 中去了,如下:

*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.externalNativeBuild

看,人家都不给你犯错误的机会呢,所以说我们还是找别的方法吧。

由于我们的项目中执行 Gradle 脚本是通过 C# 提供的执行子进程的方式来完成的,那么找不到 ANDROID_HOME 这个环境变量的进程实际上就是我们执行预处理的那个 C# 进程了。那么有没有可能单独针对这个进程设置系统变量呢,一查文档还真有,那么就可以简单的这么处理了:

gradleProcess.StartInfo.EnvironmentVariables["ANDROID_HOME"] = EditorSetup.AndroidSdkRoot;

这样一来问题自然就得到解决了,EditorSetup.AndroidSdkRoot 这个变量返回的是我们在 Unity Editor 中设置的 Android SDK 的路径,当然具体要不要用这个变量就看自己的选择了,通常来说如果我们想要正常打包 Android 的包的话,这个变量肯定是正确的 Android SDK 的路径了。

延伸思考

这个问题越想越让人觉得奇怪,你说我作为一个前 Android 开发者,怎么可能会没有配置 macOS 的系统环境变量呢,电脑上 ~/.profile 文件中早都配置好了 ANDROID_HOME 等等环境变量的啊。那么为什么现在才暴露出来呢?

  1. Android Studio 等 IDE 在 macOS 上不只是会读取 macOS 的环境变量,还会尝试去读取 ~/.profile 和 ~/.bash_rc 等等文件中设置的环境变量,所以 Android Studio 等 IDE 在启动的时候就能正确检测到我们已经在 ~/.profile 等文件中配置好的 ANDROID_HOME 变量了;
  2. Unity Editor 并不会尝试去读取 ~/.profile 等文件中设置的环境变量,而只是从其父进程 launchd 中继承了已设置的环境变量,而这些环境变量中并没有 ~/.profile 中我们通过 export 设置的各种环境变量,平时我们执行很多 Gradle 命令的时候,通常都是直接从终端中执行的,而终端程序除了会从 launchd 进程中继承已有的环境变量还会加载 ~/.profile 等文件中的环境变量的,所以导致了实际上这个问题是一直存在的只是没有暴露出来。

最终问题的根源找到了,是因为 Unity Editor 这个进程中的环境变量中缺失了 ANDROID_HOME 这个变量,那么最终需要做的就是让 Unity Editor 这样的程序的环境变量中有 ANDROID_HOME 这个变量,在杨威同学和 Google 的帮助下,最终确定的方法就是通过创建一个交给 launchctl 加载的 plist 文件,在 plist 文件中声明我们需要执行的命令 launchctl setenv 和对应的参数,让系统在启动的时候自行通过 launchctl setenv 命令将 ANDROID_HOME 等环境变量设置到 launchd 这个牛逼的父进程中去。

我们可以先看一下 macOS 系统上进程的层级关系:

我们可以看到所有进程实际上都是由 kernel_task -> launchd 这样一路 fork 出来的,看看进程 ID 也能明白得差不多了。废话少说,怎么做呢?

好吧,我参考了这篇文章

大体的流程就是这样的:

  1. 创建一个用于 launchctl 在系统启动的时候自动加载的 plist 文件,在该 plist 文件中调用 launchctl setenv 命令设置参数;
  2. 将 plist 文件放到 ~/Library/LaunchAgents 目录下去,然后重启系统即可,当然如果你现在不想重启系统,可以执行 launchctl load -w ~/Library/LaunchAgents/[com.laputa.SetAndroidEnviroment.plist] 命令(替换方括号中的 plist 文件名)来主动加载该 plist 文件,加载成功后,重新启动 Unity Editor,在 plist 文件中设置的环境变量在 Unity Editor 进程中就生效了。

把我的 plist 文件贴上来:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  <plist version="1.0">
  <dict>
    <key>Label</key>
    <string>setenv.Android</string>
    <key>ProgramArguments</key>
    <array>
      <string>/bin/launchctl</string>
      <string>setenv</string>
      <string>ANDROID_HOME</string>
      <string>/Users/helihua/Library/Android/sdk</string>
      <string>ANDROID_NDK_HOME</string>
      <string>/Users/helihua/Library/Android/sdk/ndk-bundle</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>

注意事项:plist 文件如果向上文提到的那个链接中的文章中放在 /Library/LaunchDaemons 目录下的话,重启之后貌似就不会生效,而放到 ~/Library/LaunchAgents 目录下在重启之后是可以生效的,我是在 macOS High Sierra 10.13.3 上测试的。

在Unity Editor中同步执行外部脚本

在 Unity Editor 中同步执行外部脚本

在 Unity 开发的过程中,我们都难免会碰到需要使用某些外部脚本来完成某些特定的任务,例如调用外部的 Python 脚本导入 Excel 配置表为 Json 文件等等。

C# 提供的 System.Diagnositics.Process 类可以很好的帮助我们达成这个目的,下面我们就来看看如果使用 Process 类来调用外部的脚本。

由于 C# 调用外部脚本执行的程序会新建一个进程来执行该指定的外部脚本,跟 Unity Editor 并不会在同一个进程,所以如果我们直接调用 Process.Start() 方法启动外部程序的话,该外部程序的执行与当前 Unity Editor 进程空间中执行的 C# 代码之间是无法交互的,从表现上来看,外部程序完全是独立于 Unity Editor 进程在执行。如此一来,在某些应用场景下就可能出现无法满足我们的需求,例如在每次调用 BuildPipeline.BuildPlayer 方法打包 APK 之前,需要导入最新的配置表文件,那么我们怎么确保 BuildPipeline.BuildPlayer 是在执行外部 Python 脚本导入配置表之后再执行的呢?我们来看代码:

// 同步调用外部脚本,并将其输出使用 Unity Editor 的 Log 进行输出
Process importProcess = new Process();
importProcess.StartInfo.FileName = batName;

// 如果需要在 Unity Editor 中使用 Log 输出外部程序执行时的输出进行查看的话,
// 就必须将 UseShellExecute 设置为 false
importProcess.StartInfo.UseShellExecute = false;

// 将标准输出重定向,所有输出会通过事件回调的方式在回调参数中返回
importProcess.StartInfo.RedirectStandardOutput = true;
importProcess.OutputDataReceived += new System.Diagnostics.DataReceivedEventHandler(ImportAllOutputDataReceived);

// 将错误输出重定向,所有输出会通过事件回调的方式在回调参数中返回
importProcess.StartInfo.RedirectStandardError = true;
importProcess.ErrorDataReceived += new System.Diagnostics.DataReceivedEventHandler(ImportAllErrorDataReceived);
importProcess.Start();
sImportAllOutput = new StringBuilder();
sImportAllError = new StringBuilder();
importProcess.BeginOutputReadLine();
importProcess.BeginErrorReadLine();

// 这一句很关键哦,就是因为调用了这句话,我们才能让外部程序与 Unity Editor 中的脚本同步执行
importProcess.WaitForExit();

if (!string.IsNullOrEmpty(sImportAllOutput.ToString()))
{
    UnityEngine.Debug.Log(string.Format("Import all json log:\n {0}", sImportAllOutput.ToString()));
}
if (!string.IsNullOrEmpty(sImportAllError.ToString()))
{
    UnityEngine.Debug.LogError(string.Format("Import all json error:\n {0}", sImportAllError.ToString()));
}
UnityEngine.Debug.Log(string.Format("Import all json process exited at: {0} with code: {1}, start to imprted json files to protypes",
                                 importProcess.ExitTime, importProcess.ExitCode));

// 完成导入之后,调用一下刷新方法,确保 Unity Editor 加载到的配置文件都是最新的
AssetDatabase.Refresh();   

// 执行打包操作
BuildPipeline.BuildPlayer(SceneInfoManager.Levels, androidOutputPath, BuildTarget.Android, buildOptions);

👆上面这段代码的正确执行除了需要补全一些变量之外,还需要实现两个事件回调方法:

private static void ImportAllOutputDataReceived(object sender, System.Diagnostics.DataReceivedEventArgs args)
{
   if (!string.IsNullOrEmpty(args.Data))
   {
       sImportAllOutput.AppendLine(args.Data);
   }
}

private static void ImportAllErrorDataReceived(object sender, System.Diagnostics.DataReceivedEventArgs args)
{
   if (!string.IsNullOrEmpty(args.Data))
   {
       sImportAllError.AppendLine(args.Data);
   }
}

如此一来,调用外部脚本就成为了整个工具脚本执行流程中的一个小环节了,而且执行成功或者出错,也能通过 Unity Editor 的 Log 进行查看,结合 UnityEngine.Debug.LogError 也能很好地作出相应地错误提示,是不是感觉棒棒哒。

👆上面的这段代码中是直接调用了操作系统平台的可执行脚本文件,例如在 macOS 上就是 shell 脚本,在 Windows 上就是 bat 脚本了。这样其实还是有点蛋疼对吧,我得每个平台都写一个脚本,那么为了不这么蛋疼,我们可以这么做。

// 我们最终是调用 python 来执行这个 python 脚本
string convertJsonToExcelPythonScriptFielPath = Path.Combine(Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Tools"), "convert_json_to_excel.py");

// 构建完成的参数列表
string arguments = string.Format("{0} -j {1} -e {2} -s {3}", convertJsonToExcelPythonScriptFielPath, jsonFilePath, excelFilePath, sheetName);

System.Diagnostics.Process convertJsonToExcelProcess = new System.Diagnostics.Process ();
// 针对不同的平台,找到不同的 python 执行程序
if (Application.platform == RuntimePlatform.OSXEditor) 
{
    System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo ("/usr/bin/python", arguments);
    convertJsonToExcelProcess.StartInfo = startInfo;
} 
else if (Application.platform == RuntimePlatform.WindowsEditor) 
{
    System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo (@"C:\Python27\python.exe", arguments);
    convertJsonToExcelProcess.StartInfo = startInfo;
}
convertJsonToExcelProcess.StartInfo.UseShellExecute = false;
convertJsonToExcelProcess.StartInfo.RedirectStandardOutput = true;
convertJsonToExcelProcess.OutputDataReceived += new System.Diagnostics.DataReceivedEventHandler(ConvertJsonToExcelOutputDataReceived);
convertJsonToExcelProcess.StartInfo.RedirectStandardError = true;
convertJsonToExcelProcess.ErrorDataReceived += new System.Diagnostics.DataReceivedEventHandler(ConvertJsonToExcelErrorDataReceived);
sConvertJsonToExcelOutput = new StringBuilder();
sConvertJsonToExcelError = new StringBuilder();
convertJsonToExcelProcess.Start ();
convertJsonToExcelProcess.BeginOutputReadLine();
convertJsonToExcelProcess.BeginErrorReadLine();
convertJsonToExcelProcess.WaitForExit();

好了,基于以上的两段示范代码,实际上我们已经能很好地完成在 Unity Editor 中同步执行外部程序,配合 Unity Editor 工具脚本完成各种任务了。