Unity史上最牛Bug——随机丢失你的金主玩家

想象一下,在你发布游戏后立即发现生产环境中存在一个严重的 Bug。
想象一下,这个 Bug 只会影响你的付费用户。
想象一下,它会在玩家完成应用内购买后立即卡住游戏。
想象一下,当玩家重新启动游戏时,游戏在启动过程中会卡死。
想象一下,玩家永远无法摆脱困境,只能卸载游戏。
想象一下,你的应用目前在 Apple Store 上被推荐。

这是一个关于这种 Bug 的故事,这是我在 30 年的编程生涯中遇到的最糟糕的 Bug。这是一个关于我们如何追踪它并与 Unity 合作修复它的故事。

红色警报

在 Adventure Chef: Merge Explorer  在 iOS 上上线后的 24 小时内,我们开始看到大量玩家在启动游戏时遇到卡死问题。我们使用 Bugsnag 的优秀应用稳定性监控库和仪表板。有一组调用堆栈指向 Unity 的应用内购买 (IAP) 包。显然,这个流行的 Unity 库导致我们的应用无法响应超过两秒钟,这触发了操作系统强制退出我们的应用。看起来这个 Unity 代码只是在解析一个 iOS 收据,一小段内存中的文本,以确定玩家购买了什么。

是什么会导致 Unity IAP 4.1.1 只需解析内存中的一块文本就花费超过两秒钟?

有一种极度紧迫感。我不会说这是恐慌,但 Slack 上正在发送紧急消息。我的经理六年来第一次给我发短信:

图片

 

作为游戏中负责底层 IAP 支持的工程师,我对正在发生的事情最了解,我感受到了这种责任的压力。我不会说这是恐慌,但确实,这确实很紧张。

起初,我只是试图了解发生了什么。最初,我们认为它影响了我们 10% 的 iOS 玩家。经过更仔细的检查,我们意识到大约是 1.4%。如此多的调用堆栈(程序在错误发生时正在执行的操作)是不同的。其中许多表明内存正在被分配。是否存在某种 C# 内存问题?

GC_gcj_malloc
il2cpp::vm::Object::NewAllocSpecific(Il2CppClass*) (Object.cpp)
Asn1Node_CreateAndAddChildNode (UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecodeIndefiniteLengthInternal(UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecode(UnityEngine.Purchasing.Security.cpp)
Asn1Node_InternalLoadData(UnityEngine.Purchasing.Security.cpp)
Asn1Node_CreateAndAddChildNode (UnityEngine.Purchasing.Security.cpp)
Asn1Node_ListDecodeChildNodesWithKnownLength (UnityEngine.Purchasing.Security.cpp)
Asn1Node_LoadData (UnityEngine.Purchasing.Security.cpp)
Asn1Parser_LoadData (UnityEngine.Purchasing.Security.cpp)
AppleReceiptParser_Parse (UnityEngine.Purchasing.Security.cpp)
AppleReceiptParser_Parse (UnityEngine.Purchasing.Security.cpp)
AppleStoreImpl_getAppleReceiptFromBase64String (UnityEngine.Purchasing.Stores.cpp)
AppleStoreImpl_OnProductsRetrieved (UnityEngine.Purchasing.Stores.cpp)

但所有这些调用堆栈都在 Unity IAP 的代码中,根据方法名称,似乎是在解析 Apple 收据。所以,我的理论是,我们看到的是一个无限循环,其中某种树结构正在被解析但没有完成,而各种各样的调用堆栈恰好显示了操作系统杀死我们的应用程序的位置。我收集了相关信息,并向 Unity 提交了最高优先级的错误报告。

解决方法——恢复到相对好的 Bug

在与队友交谈后,有人指出,这个 Bug 在我们使用 Unity IAP 3.2.3 的游戏之前版本中没有出现。好吧,我们升级的原因是为了改进“无可用产品”错误,在这种错误中,玩家会随机被阻止在 Android 上进行应用内购买,但他们至少可以重新启动游戏并可能成功。但这个“iOS 应用挂起”Bug 糟糕得难以想象,因此这是一个很容易的决定,恢复到 Unity IAP 的早期版本。因此,在那一刻,我们至少有一个解决方法。我们从 Unity IAP 4.1.1 恢复到 3.2.3,并进行了隔夜的离岸测试,然后将修复后的版本提交给 Apple。

由于 Pocket Gems 与 Unity 签订了合同,我们获得了优质的客户服务。客户支持在一个小时内回复了我们,IAP 团队随后也收到了通知。然后,他们说他们也可以重现卡死。哇,太快了!

随着危机的暂时解决,Unity IAP 团队正在解决问题,可能已经有了修复方案,我可以完成其他紧急任务,因为我们正要进入冬季假期。

ASN.1 和深度挖掘

我仍然感到困扰的是,我们不知道如何重现这种卡死。Unity IAP 团队表示他们可以重现卡死,但它是否与我们遇到的卡死相同?我该如何验证他们即将发布的修复程序?我们可能会永远停留在 IAP 3.2.3 上。

进入冬季假期,由于疫情,我有很多时间,而且不想旅行。我真的很想知道是否可以重现这个问题。我试图理解 Unity IAP 代码在做什么。看起来它正在从 Apple 收据中构建一个树结构,Apple 收据是一个以 Base64 编码的文本字符串,表示 ASN.1 格式的跨平台二进制结构。

ASN.1 是一个层次结构,包含容器状元素和简单属性的叶节点:

图片

 

至关重要的是,如果你没有模式或外部数据布局描述,那么字节块,就像上面的字节字符串,可能是一个子 ASN.1 结构,也可能只是一个包含某些字符串的叶节点。父结构无法告诉你哪种方式!因此,要解码任意 ASN.1 对象,你只需要尝试解析每个元素,看看会发生什么。从架构和安全角度来看,尝试解码随机二进制块以查看它是否恰好是一个定义良好的结构,这很冒险,让我想起了 小鲍比表。

图片

Unity IAP 是否是通过尝试解析随机字节而陷入困境的?剧透警告:是的,确实是。

重现

我决定构建一个自动单元测试,它可以在 Unity IAP 包中仅运行 Apple 收据解析代码,而无需在 iOS 设备上调试我们的游戏的所有开销。不幸的是,在为 Unity 编辑器的播放器构建时,他们的代码不包含在内,但我能够从以下目录中复制 C# 文件,该目录位于我的本地 Unity 项目目录的根目录:/Library/PackageCache/com.unity.purchasing@4.1.1/Runtime/Security/Asn1Processor/

对于数据,我们将玩家的匿名收据保存到 Google BigQuery 表中,因此我运行了一个查询,查看了我们实际 iOS 玩家应用内购买的一小部分收据。我将数据下载为 CSV 文件,并编写了一段简单的解析代码。我只有 300 多个真实收据。

我能重现卡死吗?我将我的 C# 调试器(在 JetBrains 的优秀 Rider IDE 中)附加到 Unity 编辑器,并运行我的新单元测试。我跨越了对 Unity 的 ASN1 收据解析代码的调用。下一行代码没有执行。单元测试正在运行。卡死。第一个收据重现了卡死!重新启动。跳过第一个收据。第二个收据解析正常。第三个和第四个也是如此。我让单元测试自由运行。再次卡死。

我从 Unity IAP 3.2.3 中复制了相同的代码文件,以再次确认它是否可以解析这 300 个收据。是的,没问题。

我太高兴了!我可以重现问题!现在更多收据!

我在自动测试中遇到了一个有趣的问题——如何测试数千个收据,知道其中一些会导致无限循环,但我希望我的测试能够完成并输出结果?

一种方法是为每个收据分配一个任务,然后使用 .NET 的 WaitAll 方法以及超时参数。任务在 .NET 线程池中的后台线程上运行,因此你的主线程不会被阻塞,并且可以报告结果。

// 创建一个任务来解析每个收据。
var receiptParsingTasks = new List<Task>();
foreach (string line in File.ReadLines(receiptsPath))
{
    Task task = Task.Run(() => ParseReceipt(line));
    receiptParsingTasks.Add(task);
}

// 解析每个收据应该非常快。如果经过几秒钟,那么
// 它实际上是一个无限循环。
bool completedOnTime = Task.WaitAll(receiptParsingTasks.ToArray(), 5000);
if (!completedOnTime)
{
    int firstIncompletedIndex = receiptParsingTasks.FindIndex(
        task => !task.IsCompleted);
    Debug.LogError($"第 #{firstIncompletedIndex + 1} 行导致冻结。");
}
Assert.IsTrue(completedOnTime);

在 9,163 个收据中,2 个导致崩溃,180 个导致卡死,8,981 个解析正确。错误率:2.0% (= 182 / 9163)。

更好的解决方法

在等待 Unity 的修复程序时,我们意识到我们需要为 Android 使用一个版本的 Unity IAP,为 iOS 使用另一个版本。我们正在绕过两个 Bug!

  • Unity IAP 3.2.3——将其用于 iOS。它具有“无可用产品”Bug,该 Bug 几乎只影响 Android,但重要的是,它不包含“iOS 应用挂起”Bug。

  • Unity IAP 4.1.1——将其用于 Android。它改进了 Android 的“无可用产品”错误,但它引入了“iOS 应用挂起”Bug(该 Bug 只影响 iOS,不影响 Android)。

但是如何根据平台选择包版本呢?我的同事知道一个优雅的解决方案——你可以在 Unity 编辑器中加载项目时以编程方式选择它!包管理器中的默认值为 Unity IAP 3.2.3,我们的构建器将为 Android 选择 Unity IAP 4.1.1

[InitializeOnLoadMethod]
private static void LoadUnityIAPPackage()
{
    // 为了避免更改每个人的本地 packages-lock.json 和 manifest.json
    //  让我们只在 Jenkins 上切换 Android 版本。
    if (Application.isBatchMode &&
        EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android)
    {
        UnityEditor.PackageManager.Client.Add("com.unity.purchasing@4.1.1");
    }
}

来回修复

我向 Unity IAP 团队索要他们修复程序的预览,以再次确认它通过了我的自动化测试。他们同意了,但当我尝试他们的修复程序时,它不幸的是没有修复卡死。

我很难确定我重现卡死的方式是否有效。如果我误用了他们的代码怎么办?我感觉到我们在支持票上的来回交流中存在困惑。因此,在一个星期四下午,我要求与他们的程序员会面。他们同意了,并在第二天早上 10 点安排了一次会议。哇,客户支持真棒!

他们解释了他们认为卡死的原因——它是由 Unity IAP 4.x 改进了对 Unity 编辑器播放器中假商店的支持造成的,通过对这些 ASN.1 结构进行更深入的解析。他们确认我的自动化测试是有效的。

根本原因

更深入的解析是什么意思?想象一下,你在 ASN.1 对象中看到这个八位字节字符串:

5285A91861B12FC85E94CF4C6E521B094…

ASN.1 结构没有说明如何解释这个八位字节字符串;它只是一个某些二进制数据的文本表示。按照惯例,Apple 收据中的一些八位字节字符串表示一个独立的 ASN.1 编码对象。哪些是?好吧,Apple 定义了这一点,但我猜想弄清楚这一点有点麻烦,而且尝试解码并看看会发生什么更容易!YOLO!

ASN.1 格式具有 短标签,用于指示结构的类型。例如,0x3 表示位字符串,0x4 表示八位字节字符串。还有用于容器的标签,如集合 (0x11) 和序列 (0x10) 等。因此,随机字节很容易被误认为是标签。

Unity IAP 团队发现自己不得不加强他们的代码,以抵御可能来自不受信任的敌对来源的随机数据,就像上面的小鲍比表漫画一样。正确解析格式错误的复杂结构是一个难题。这就是导致未处理的异常和无限循环的原因。

无论如何,我很快获得了他们修复程序的第二个预览。它修复了卡死!但是,它在另一个收据上崩溃了。我也给了他们那个收据。

第三次尝试——看起来不错!所有 9,163 个收据都已干净地解析!

很快,Unity IAP 4.1.3 带着修复程序发布了。呼!然后我可以摆脱我们笨拙的解决方法。

发布

我很欣慰我们的离岸测试和自动化测试在 Unity 的 IAP 4.1.3 版本中看起来很干净。我们使用这个修复程序发布了 Adventure Chef 的新版本,一切看起来都很好。

我很高兴能帮助其他基于 Unity 的游戏开发者,他们可能甚至不知道他们的某些客户遇到了这个问题。我很高兴能帮助我们的合作伙伴 Unity。

以下是 Unity IAP 4.1.3 变更日志 中的摘要;很多工作和压力都隐藏在这句看似无辜的句子后面!

“修复了 Apple StoreKit 收据解析失败的边缘情况,阻止了验证。”想了解更多游戏开发知识,


可以扫描下方二维码,免费领取游戏开发4天训练营课程

  • 15
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值