分类目录归档:Unity3D

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中使用Gradle打包出现Heap-Space不足的错误

如何解决 Unity Editor 中使用 Gradle 打包出现 Heap Space 不足的错误

最近在自己的开发机上和公司内部用的打包的机器上都出现了通过 Unity Editor 提供的 Gradle 打包流程打包 APK 文件时提示 Java Heap Space Error 的问题。

输出的错误日志内容如下:

Running dex as a separate process.

To run dex in process, the Gradle daemon needs a larger heap.
It currently has 1024 MB.
For faster builds, increase the maximum heap size for the Gradle daemon to at least 2560 MB (based on the dexOptions.javaMaxHeapSize = 2g).
To do this set org.gradle.jvmargs=-Xmx2560M in the project gradle.properties.
For more information see https://docs.gradle.org/current/userguide/build_environment.html

:mergeDebugJniLibFolders UP-TO-DATE
:transformNativeLibsWithMergeJniLibsForDebug
:processDebugJavaRes NO-SOURCE
:transformResourcesWithMergeJavaResForDebug
:validateSigningDebug
:packageDebug
Expiring Daemon because JVM Tenured space is exhausted
Daemon will be stopped at the end of the build after running out of JVM memory
:packageDebug FAILED

BUILD FAILED

Total time: 1 mins 26.532 secs
Expiring Daemon because JVM Tenured space is exhausted
]
exit code: 1

那就按照它的提示把 gradle 的配置修改一下吧,由于我使用的开发机和公司的打包机器都是 macOS 系统,所以只需要在 ~/.gradle 目录下的 gradle.properties 文件(如果该目录下没有这个问题件,可以通过 touch gradle.properties 命令创建)中新增下面的这行配置即可:

org.gradle.jvmargs=-Xmx2048m

如何优雅地应用 Google Play Obb 机制

先说点闲话

近期我们的游戏 SailCraft 准备上线一个新的版本,在该版本中我们采用了 APK + Obb 文件的方式来实现资源的分发,尽可能减少玩家直接通过 CDN 下载游戏资源文件的概率。这是基于前一段时间我们游戏在 Google Play 中获得了一周的新游推荐之后,从玩家注册转化率数据上发现了在部分地区的玩家转化率明显不正常,跟踪分析之后发现是由于我们客户端的一个错误实现 + 玩家所在地区网络带宽太小这两个原因,致使玩家无法顺畅地进入游戏的事实得出来的判断。由于我们在海外 Android 的分发完全依赖 Google Play,这样就意味着我们所有的用户都能顺畅地从 Google Play 下载安装游戏,依托 Google Play 在全球的网络优化,我们完全可以不用考虑安装包分发的问题。由于时间的原因和我个人在 Obb 上的一些不太愉快的经历,之前确定资源文件分发方案的时候,我们过于理想化地认为只要我们选择最好的 CDN 厂商,应该就能解决我们的问题,所以最终我们在上线时并未考虑使用 Google Play 提供的 Obb 文件机制。

最终事实告诉我们 Google Play 在全球的网络优化和分发能力远不是我们这种小团队可以想象的,而且使用 Obb 文件还有一个额外的好处就是可以大量减少我们使用的 CDN 服务的流量费用。经历了这次 Google Play 全球新游推荐,让我们更多地接触到了平时可能触及不到的一些地区的玩家,也帮我们收集了更多数据更是直接反映出来了很多问题,其中之一就是使用 Obb 文件的必要性。既然箭已在弦上,那就不得不搞了。我们选择的做法是,直接将未打包在 APK 里头的所有资源打包成 Zip 包,作为 Obb 文件上传到 Google Play 后台,下载成功后首次启动游戏时,将 Zip 中文件解压存放到游戏专属的 SD 卡的 data 子目录中,后续的资源更新也是直接将更新资源下载到该 data 子目录中。

确定方案

那么我们先来梳理一下 Google Play 是如何处理 Obb 文件的,先来看看我们为什么要用 Obb 文件:

  1. Google Play 目前对于 API 等级 9 以上的 APK 支持最大文件大小为 100M,对于 API 等级为 8 及以下的 APK 文件大小限制为 50M(那种设备对于我们游戏开发厂商来讲毫无价值,我们也不会支持)。
  2. 目前绝大部分游戏厂商的产品最终的文件大小都是超过 100M 的,我们目前线上的版本完整包的大小时 130M,所以我们的做法是将 APK 大小控制在 95M 左右,将其他的 40M 大小的资源文件打包成 Zip 包作为 Obb 文件进行上传。

接下来我们得确定我们的 Obb 文件究竟是以什么样的形式呈现,以及我们游戏又该如何跟 Obb 文件进行交互。

由于 Android 系统自身实际上对于 Obb 文件并没有什么实质上的规范和要求,甚至我们可以理解为这只是 Google 在 Google Play 这个服务的基础上提供的一个解决方案而已,并非 Android 系统的一个基础设施,本质上 Android 系统只是将 Obb 文件以及其存放的目录当作普通的文件和目录来处理罢了,所以我们选择直接将所有的资源文件打包成 Zip 包进行上传作为 Obb 文件。Google Play 支持一个 APK 最多可以上传两个 Obb 文件,一个 Main Obb,一个 Patch Obb,每个 Obb 文件的大小上限为 2G,通常 Main Obb 不经常修改建议可以大一些,更新最好以 Patch Obb 文件的形式上传,这个文件可以小一些,在后续版本更新中可以持续更新这个文件。

目前我们暂时不考虑使用双 Obb 文件的方法了,毕竟当我们需要更新版本的时候,通常都需要更新 APK 包,而 APK 包的大小已经接近了 100M,玩家通常也会选择在有 Wi-Fi 网络的情况下进行安装包的更新,所以如果我们真的有必要更新 Obb 文件时,选择跟版本更新同时更新 Main Obb 文件问题应该不大。不过我们确实可以将 Patch Obb 用来做资源的更新,只是这样对整个资源打包流程的改动较大,特别是制作 Patch Obb 的流程会变得很复杂,对于代码的处理逻辑来说也相对来说会更复杂一些,还需要考虑到线上资源更新与 Patch Obb 资源更新之间如何取舍的问题,在第一个版本中可以暂时不考虑,后续可以再完善。

既然这样的话,那么当我们从 Google Play 中下载安装游戏时,Google Play 会先在 /sdcard/Android/obb 目录下创建一个以游戏包名(例如:com.seabattle.uq)命名的目录,然后将我们上传到后台的 Obb 文件下载到这个目录下。首次启动游戏时,我们可以直接将该 Obb 文件当作 Zip 包进行解压,将解压的资源文件保存到 /sdcard/Android/data/com.seabattle.uq/ 目录下,游戏内读取资源只从 APK 包内部和该目录进行读取即可。如果后续有某个资源文件需要进行更新,可以直接从 CDN 上下载,将更新的资源文件也保存到该目录即可。

开始编码

确认好了具体的方案之后,我们就可以开始编码了。看上去我们需要做的调整并不多,目前看来只需要做几件事情,我们就可以享受 Obb 机制带来的巨大利好了,对伐?

  1. 把原来需要从 CDN 下载的资源文件整理好压缩成一个 Zip 包,在上传 APK 到 Google Play 后台时,将其作为 Obb 文件同时上传;
  2. 在游戏首次启动的时候判断一下是否成功解压过 Obb 文件,如果没有解压过 Obb 文件,就直接从 /sdcard/Android/obb/com.seabattle.uq/ 目录下找到我们需要解压的 Obb 文件,直接使用相应的 Zip 库将文件解压到 /sdcard/Android/data/com.seabattle.uq/ 目录下,然后直接走正常加载资源进入游戏的流程就好了。

当然上面的两步是一个基础,也是一定要做的,但是只做这两步的话还是远远不够的,我们来看看会有哪些问题。

由于 Android 系统相对开放,大家随时都可能通过各种手段访问和操作 SD 卡,而 Obb 文件就是存在在 SD 卡上的,也就是说在游戏 App 依然安装在设备上的同时,Obb 文件是否可用是没有绝对保证的,主要有以下几种可能导致 Obb 文件不存在:

  1. Google Play 在下载安装 APK 的时候,未能成功地将 Obb 文件下载下来,这个 Google 官方的说法是这样的:Expansion files are hosted at no additional cost. When possible, Google Play downloads expansion files when apps are installed or updated. In some cases, your app will need to download its expansion files.
  2. 玩家不小心错误地将游戏对应的 obb 目录下的 Obb 文件给删除了(注意这里只考虑文件被删除的情况,因为目录被删除了的话就是另外一种情况了,而且目录被删除的话会更麻烦)。

那这个时候玩家的设备上都没有 Obb 文件,我们怎么办,解压个鬼啊?别担心,Google 自己也是考虑到了这些情况的,所以 Google 官方是有提供一个完整的 Obb 文件下载解决方案的,就是为了让大家可以快速地集成一个手动从 Google Play 下载 Obb 文件的服务到我们已有的项目里头来的,代码就在[ANDROID_SDK_PATH]/extras/google/目录下,分别是以下三个目录:

  1. market_apk_expansion/downloader_library
  2. market_apk_expansion/zip_file
  3. market_licensing

将这三个工程作为 Library Project 导入到 Android 工程中就可以直接使用了,具体如何调用这个服务可以参考market_apk_expansion/downloader_sample中的实现。

不过在集成这几个 Library 到项目里时有几个地方需要注意一下:

  1. Google 提供的这个解决方案实现的时代已经很是久远了,而目前已经疏于维护了,所以这个解决方案中的三个 Library 工程的编译 SDK 等级都只能设置为 15,过高的话可能会出现某些工程中引用的 API 已经在高版本的 SDK 中被移除了导致无法编译的问题;
  2. 这个解决方案中使用的 Notification 相关的代码实在太老了,在运行时会直接输出错误级别的日志,最好时引入一个 Support 库,然后通过 NotificationCompat 的方式来替换那些古老的实现;

所以我们集成这个 Obb 下载服务到咱们游戏内的目的就是为了解决玩家首次启动游戏时,设备 SD 卡上游戏专属的 obb 目录下的 obb 文件不存在或者未下载成功的问题,虽然可能用到的概率很小,但是为了玩家,上吧。当我们把 Obb 文件丢失或者下载不成功的骨头啃完了以后,是不是就可以歇歇了呢?

意料之外的“惊喜”(坑)

我想你也看到了我在上文提到了一个 /sdcard/Android/obb/com.seabattle.uq/ 目录都被删除的情况了对吧,这个情况就更加复杂和麻烦了,为了要应对这种情况,如果我们的游戏没有申请读写 SD 卡的权限的话,我们会想到以下的一些可能的解决方法:

  1. 提示玩家,“对不起,你 SD 卡上的这个 obb 目录不见了,我们啥也干不了,请卸载我们的游戏,然后重新从 Google Play 里头下载安装一遍吧”,你觉得玩家会怎么说?“草泥马,卸载”。
  2. 是否可以尝试调用 Google 提供的这个 Obb 下载的服务去重新下载 Obb 文件呢?对不起,不可以,因为咱们没有申请读写 SD 卡的权限,所以目录被删除了之后,Obb 下载服务自己也是没有创建目录的权限的,因为这个服务就是集成在游戏内的,游戏申请了哪些权限决定了游戏内所有的接口调用的权限,所以死了这条心吧,在 obb 目录被删除之后,重试调用 ObbDownloadService 去下载 Obb 文件会直接失败的。
  3. 那么我们总不能真的回到方法 1 吧,也不能不让玩家玩游戏啊。那么此时我们就只能选择直接从 CDN 处下载资源文件了,下载成功后就可以进入游戏了。

那我们吭哧吭哧地把代码写好了,包也打出来了,测试一下吧。你等着,还有更多惊喜在等着你哦。

在我们将打包的 APK 包和 Obb 文件上传到 Google Play 后台,并且发布到 Beta 版本后,我们使用测试帐号进行了下载安装测试,发现了一个比较诡异的问题,那就是在某些手机上(Samsung S7, S7 Edge,红米 Note,乐视 Max 等),首次启动的时候,游戏并没有主动去解压已经成功下载到 /sdcard/Android/obb/com.seabattle.uq/ 目录下的 main.25.com.seabattle.uq.obb 文件,而是选择了直接从 CDN 处下载资源文件。但是在某些手机上(Google Nexus 6)一切正常,卸载重试安装多次,都是正常的。

继续研究

最终通过查看日志,发现在出现问题的这些设备上,游戏首次启动的时候,对 /sdcard/Android/obb/com.seabattle.uq/ 目录就没有读取的权限,所以客户端的逻辑判断认为 Obb 文件不存在,然后就启动了 Obb 下载服务去下载 Obb 文件了,但是也不知道为啥这个 Obb 下载服务竟然能判断出来 main.25.com.seabattle.uq.obb 文件已经下载成功了,不需要再进行下载了,然后就直接回调了下载 Obb 文件成功的逻辑,然而实际上它压根儿就没有权限访问 main.25.com.seabattle.uq.obb 这个文件。

针对这个 Obb 下载服务能正确判断 Obb 文件存在,但是我们却无法访问的问题,今天细细探究了一番,最终发现 Android 中对 File.exists 方法的实现跟 JDK 中的实现是有区别的,在 Android 中的代码是这样的:

public boolean exists() {
    return doAccess(F_OK);
}

private boolean doAccess(int mode) {
    try {
        return Libcore.os.access(path, mode);
    } catch (ErrnoException errnoException) {
        return false;
    }
}

从上面的代码段中我们可以看出这货不会像 JDK 中抛出 SecurityException 这样的运行时错误,我们可以看看 JDK 中的访问文档是怎么样的:

public boolean exists()

Tests whether the file or directory denoted by this abstract pathname exists.

Returns:

true if and only if the file or directory denoted by this abstract pathname exists; false otherwise

Throws:

SecurityException – If a security manager exists and its SecurityManager.checkRead(java.lang.String) method denies read access to the file or directory

那么这个 F_OK 是个什么鬼啊,这就要去看 Linux 中 access(2) 的文档了,因为 Android 实际上最终调用的就是这个方法。

access() checks whether the calling process can access the file
pathname. If pathname is a symbolic link, it is dereferenced.

The mode specifies the accessibility check(s) to be performed, and is
either the value F_OK, or a mask consisting of the bitwise OR of one
or more of R_OK, W_OK, and X_OK. F_OK tests for the existence of the
file. R_OK, W_OK, and X_OK test whether the file exists and grants
read, write, and execute permissions, respectively.

由此我们可以得出结论了,由于 Android 中 File.exists() 方法判断一个文件是否存在并不需要调用者有对该文件的任何访问权限,这个方法只是判断文件是否真实存在,由于该文件确实是存在的,所以即便调用者对于该文件没有任何访问权限都会返回 true,致使 Obb 下载服务确实可以正确的判断 Obb 文件成功,而我们的代码逻辑选择了直接信任 Obb 下载服务返回的结果,认为只要返回下载成功就可以直接访问该 Obb 文件了,但是后续我们尝试通过其他的接口来列举 obb 目录下的文件清单和单独访问该文件都会出现权限异常的问题。

客户端在下载 Obb 文件成功的回调中,会主动调用解压 Zip 包文件的逻辑,但是由于最终无法访问 /sdcard/Android/obb/com.seabattle.uq/ 目录和 /sdcard/Android/obb/com.seabattle.uq/main.25.com.seabattle.uq.obb 文件,导致解压 Obb 文件的流程失败了,然后就只能走 CDN 下载的流程了。

我们可以看看这个截图,这是从 Google Play 下载安装成功游戏后,游戏专属的 obb 目录的权限的截图:

安装游戏成功后obb目录权限截图

这个问题看上去这么诡异,我们放狗搜一下吧,看看有没有人也遇到了类似的问题,不搜不知道,一搜吓一跳。原来在 Android 官方的 Issue Tracker 中已经有了两个跟我们一模一样的问题,而且别人还找到了怎么让这个问题自动修复好的办法,那就是重启手机。这两个问题的链接在这里:
https://issuetracker.google.com/issues/37544273https://issuetracker.google.com/issues/37075181

那么重启一下手机之后,游戏专属的 obb 目录的权限会变成什么样呢:

重启手机后正确的obb目录权限截图

至此我们已经可以很确定地说,在大部分的 Android 设备上会存在这个 obb 目录权限出错的问题,这会导致游戏在不申请读写 SD 卡权限的情况下,在这些设备上无法正常地读取已经从 Google Play 上下载成功的 Obb 文件。由于我们游戏目前对于 SD 卡的读写权限是这么设置的:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18" />

所以在 Android 5.0 以上的设备上,实际上我们游戏是只能读写 SD 卡上专属的两个目录,/sdcard/Android/obb/com.seabattle.uq/ 和 /sdcard/Android/data/com.seabattle.uq/,但是由于 Android 或者说是 Google Play 的这个未能正确设置 obb 目录权限的问题,我们就无法在 SDK 等级为 18 以上的设备上正确地读取并解压 Obb 文件。这下蛋疼了,尼玛做了这么多,你告诉我,这都白干了?

这个时候我们就只能根据实际情况来做判断了,鉴于 Android 设备的各种奇怪设定我们已经见怪不怪了,而且我们也看到了在 Android 的 Issue Tracker 中其他开发者提到的受影响的设备和 Android 版本的情况,所以我们可以初步判断这个问题可能影响到的设备数量级较大,而且目前并没有什么更好的解决方案,如果我们想利用 Google Play 提供的 Obb 文件带来的益处,就只能考虑申请 SD 卡读写权限了。

最终的选择和方案

基于这个问题,我们也请教了腾讯游戏的开发者,沟通的过程中得知腾讯所有发海外的游戏通常都会主动申请 SD 卡读写权限,他们貌似都没有遇到过我们这个问题。好吧,既然这样,那我们多看看其他的厂商是如何处理的吧。

  1. 《炉石传说》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,但是下载速度惊人的快且稳定;
  2. 《游戏王》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,下载文件之多,简直惊人,速度也较快且稳定;
  3. 《剑与家园》,使用 Obb 文件,游戏启动后主动申请 SD 卡读写权限,获得权限后解压 Obb 资源;

这么看来一线大厂们基于自己多年分发游戏的积累,已经形成了一套非常稳定可靠的资源更新下载的机制,可以不依赖单个分发平台提供的便利机制,而使用自有的资源分发机制,这样可以降低项目的复杂性和不同平台上维护的难度,不失为一种可行的方案。但是对于中小厂商,由于在全球发行上并未积累太多的经验,很大的程度上选择依托 Google Play 这样成熟的平台会更有优势,所以此时可能只能做退一步的选择了,那就是为了尽可能利用 Google Play 提供的 Obb 文件分发机制减少使用 CDN 可能带来的下载问题和流量费用,但是鉴于目前可能存在的 obb 目录读取权限的问题,在目录出现访问权限的问题时主动申请权限,如果在某些设备上刚好运气不错可以直接访问 obb 目录下的内容的话,就可以直接进行解压了,不需要动态申请该权限了。至于这会影响到多少玩家因为游戏主动申请 SD 卡权限而选择放弃这款游戏或者去 Google Play 中给一个差评,这就很难讲了。作为技术执行者,我们能做到的这已经是极致了。

最终整个方案的处理流程图如下:

Android Google Play Obb 机制流程图

c#中如何正确处理utf8-with-bom的读写问题

C# 中如何正确处理 UTF-8 with BOM 的读写问题

昨天把项目中的打包工具做了一些调整,原本正常工作的代码因为新增的部分代码执行流程出现了问题,而且问题比较隐晦。最终通过调试跟踪,发现问题出在解析一个 JSON 文件的时候,该文件中只是存放了一个 JSON 数组而已。之前的版本中解析的文件是直接从服务器下载下来的,而服务器上的文件是在另一个工程中生成的,调整后的项目中,该 JSON 文件是自己生成的。

这个问题的关键在于我们通过以下的代码读取出来的字符串中有特殊字符:

string filePathUTF8 = "/Users/helihua/Temp/poerty_utf8.txt";
byte[] bytesUTF8 = File.ReadAllBytes(filePathUTF8);
string decodedPoetryUTF8 = System.Text.Encoding.UTF8.GetString(bytesUTF8);

直接使用 JSON 解析库解析读取出来的文本会出错,因为这段文本的第一个字符实际上是一个特殊不可见字符(表达零宽度非换行空格的意义,是不是很牛逼,调试的时候输出日志是发现不了的,调试的时候直接查看整个字符串的内容也是查看不了的,只能通过判断字符串长度和字符串第一个 char 才可以分辨噢),是 UTF-8 的 BOM,也就是 U+FEFF 这货,在 UTF-8 编码的文件中表现为前三个字节为: 239 187 191。

仔细分析后,我发现问题出在新调整的代码中,生成新的 JSON 文件使用了 File.WriteAllText(string path, string contents,Encoding encoding) 方法,传入的 encoding 为 System.Text.Encoding.UTF8。而这货默认是开启 BOM 的,那么这就意味着我们新创建的 JSON 文件是 UTF-8 with BOM 编码格式的(其实就是在写入文本的字节数据之前,添加了一个 BOM 块,也就是文件头部多了 3 个字节)。

然而后续读取该 JSON 文件的时候我并没有使用 File.ReadAllText 方法来进行文本内容的读取,而是先通过 File.ReadAllBytes 方法将 JSON 文本文件的所有数据读取为字节数组,后续通过了 UTF8Encoding.GetString 方法将字节数组转化为字符串。而 Encoding.GetString 方法是不会自己去过滤我们获得到的文件字节数组中的 BOM 头对应的 3 个字节的,所以就将其解析成了一个零宽度非换行空格了,最终导致解析 JSON 失败,整个程序流程出错了。

那么后续我们应该如何来规避类似的问题,正确地处理 UTF-8 with BOM 的读写问题呢?我的建议是:

  1. 尽可能让写文件和读文件采用对应的方法,例如写入文本文件的时候,使用 File.WriteAllText 方法,那么在读取文本文件的时候,就应该使用 File.ReadAllText 方法(C# 默认的实现非常鸡贼,在写入文件的时候会按照你传入的 Encoding 中声明是否需要写入 BOM 来写入文件,但是在读取文本的时候,不论传入的 Encoding 中是否声明带有 BOM,它都会检测 BOM,并且会把 BOM 从读取出来的字符串中移除掉,也就是说我们通过 File.ReadAllText 读取出来的文本字符串肯定是干净的,不会出现 BOM 这种奇怪的捣乱分子的);
  2. 在使用上面的方法的同时,使用相同的编码方式,并且建议使用不带 BOM 的编码方式(BOM 更多是为了给文本编辑器检测文件编码用的,对于 BOM 的各种争端,就如同编辑器 VIM 和 Emacs 之间的圣战般激烈,在此我表个态,我认为尽可能不用 BOM),如此一来所有的 UTF-8 都是不带 BOM 的,也就不存在在某些场景下读取文件的人没意识到文件头部可能会有 BOM ,编码过程中完全没有考虑到该问题,最终读取出来的文本中含有特殊字符与预期结果不一致的可能性;
  3. 如果在实际开发中,无法确定文件是什么类型,或者写文件和读文件的代码模块不由同一个人来开发维护,那么就需要非常明确地通过文档规范来声明读写文件应该采用什么方式,通用的方式当然就是通过 StreamWriter 来写入字节,通过 StreamReader 来读取字节,至于需要如何应用这些字节,可以交给上层的应用自行来处理。

在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 工具脚本完成各种任务了。