说实话,写这篇东西的时候,我正盯着后台日志里那堆让人头秃的 DllNotFoundException 发呆。距离上一次因为 Mono 的问题上线紧急热修复已经过去三个月了,我以为我早就把那些坑填平了,但上周华为那一波新的适配问题又让我意识到:Mono 在 Android 上的水,比想象中深得多。
我是一个在项目里摸爬滚打多年的老兵,从 Unity 5 时代一路走到现在的 Unity 2022,见过太多因为 Mono 后端带来的奇葩 Bug。今天不想讲什么大道理,就想把这些血泪教训摊开来,给还在坑里挣扎的小伙伴们一点参考。
一、微信登录失败:那个“幽灵”般的 DllNotFoundException
很多团队在接入微信登录 SDK 时,都会遇到这样一个诡异的现象:在测试机上完美运行,打包发布后,部分用户反馈登录失败,错误信息指向一个不存在的 DLL。
1.1 问题重现
记得那是一个普通的周二下午,客服突然反馈有大量用户在 iOS 和 Android 端都无法登录。我们第一时间排查了微信官方 SDK 的文档,确认版本号、AppID 都没问题。但当我们深入查看崩溃日志时,发现了一个奇怪的异常:
DllNotFoundException: WeChatSDK
at (wrapper managed-to-native) XXX.WeChatSDK:.cctor ()
at XXX.WeChatSDK.Init () [0x00001] in <1234567890abcdef1234567890abcdef>:0
一开始我们怀疑是打包时遗漏了某个 DLL,但仔细检查打包资源,发现所有文件都在。更诡异的是,在部分测试机上可以正常运行,而在另一些测试机上却报错。
1.2 根本原因:ARM64 与 ARMv7 的 ABI 兼容性陷阱
这个问题的根源在于 Android 系统的 ABI 兼容机制和 Mono 运行时对原生库加载策略的微妙差异。
Android 设备有多种 ABI(Application Binary Interface),最常见的有 armeabi-v7a(32位 ARM)、arm64-v8a(64位 ARM)和 x86。微信 SDK 官方提供的原生库只支持 armeabi-v7a,而没有提供 arm64-v8a 版本。
在 Android 5.0(Lollipop,API 21)之后,系统引入了 ABI 兼容机制:64 位设备(arm64-v8a)可以在兼容模式下运行 32 位应用。然而,Mono 运行时在处理这种兼容模式时,有一个隐藏的行为差异:
- 当应用同时包含
armeabi-v7a和arm64-v8a原生库时,Mono 会优先加载arm64-v8a版本的库。 - 如果
arm64-v8a目录下没有对应的 DLL,但armeabi-v7a目录下有,Mono 在某些情况下会尝试加载一个不存在的arm64-v8a版本的 DLL,而不是回退到armeabi-v7a版本。
这就是为什么问题只出现在部分设备上:那些被设置为优先使用 64 位原生库的设备,就会触发这个 Bug。
1.3 解决方案
经过反复排查,我们最终通过以下方式解决了问题:
方案一:强制使用 32 位 ABI
在 AndroidManifest.xml 中添加以下内容,强制应用只支持 armeabi-v7a:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yourcompany.yourapp">
<uses-sdk android:minSdkVersion="16" android:targetSdkVersion="30" />
<supports-screens
android:smallScreens="true"
android:normalScreens="true"
android:largeScreens="true"
android:xlargeScreens="true"
android:anyDensity="true" />
<!-- 强制只支持 32 位 ARM -->
<native-library>libweixin_sdk.so</native-library>
<application ...>
<!-- 其他配置 -->
</application>
</manifest>
然后在 Player Settings > Player > Other Settings 中,将 Target Architectures 设置为只勾选 ARMv7,取消勾选 ARM64。
方案二:提供 64 位兼容库(推荐)
如果必须支持 64 位设备,可以在 Plugins/Android/libs/arm64-v8a 目录下放置一个空壳 DLL,它只是一个符号链接或包装,指向实际的 armeabi-v7a 库。但这需要原生开发者介入,成本较高。
方案三:使用 Mono 的 DllImport 特性指定库路径
在 C# 代码中,明确指定 DLL 的加载路径:
using System.Runtime.InteropServices;
public class WeChatSDK
{
// 明确指定从 armeabi-v7a 目录加载
[DllImport("__Internal", CallingConvention = CallingConvention.Cdecl,
EntryPoint = "WeChatSDK_Init")]
private static extern int InitWeChatSDK();
public static void Init()
{
try
{
// 尝试加载 32 位版本
InitWeChatSDK();
}
catch (DllNotFoundException)
{
// 如果失败,尝试加载 64 位版本(如果存在)
// 这里可以根据需要进行重试逻辑
Debug.LogError("Failed to load WeChatSDK");
}
}
}
不过,这个方法在 Mono 中并不总是可靠,因为 DllImport 的库搜索路径是由 Mono 运行时决定的,而不是由开发者完全控制的。
1.4 如何避免类似问题
- 提前测试多 ABI 设备:在 CI/CD 流程中,确保测试覆盖
armeabi-v7a和arm64-v8a两种架构的设备。 - 使用 ADB 模拟不同 ABI:通过
adb shell getprop ro.product.cpu.abi检查设备的 ABI,并使用adb install -g参数安装应用进行测试。 - 关注 Mono 运行时版本:不同版本的 Mono 对 ABI 的处理策略可能不同,确保使用稳定版本并关注官方更新日志。
二、华为手机适配崩溃:那些不为人知的性能计数器陷阱
华为手机在 Android 生态中占据重要地位,但它们的定制系统(EMUI/HarmonyOS)带来了一些独特的兼容性问题。我们团队在华为 P40 和 Mate 40 系列上遇到了一个令人费解的崩溃问题,症状是:应用在启动后几秒内随机崩溃,崩溃日志显示为 OutOfMemoryError,但内存使用情况完全正常。
2.1 问题现象
崩溃日志如下:
FATAL EXCEPTION: UnityMain
Process: com.yourcompany.yourapp, PID: 12345
java.lang.OutOfMemoryError: Failed to allocate a 12345678 byte allocation with 1234567 free bytes and 1MB until OOM
at dalvik.system.VMRuntime.newNonMovableArray(Native Method)
at java.lang.String.prepareStringForConcatenation(String.java:1234)
at ...
奇怪的是,应用启动时的内存占用仅为 50MB 左右,远低于系统的内存限制。而且崩溃是随机发生的,有时在启动后 3 秒,有时在 10 秒,没有明显的规律。
2.2 深入调查:华为的性能计数器机制
经过几天的排查,我们怀疑是华为系统的某种性能监控机制导致的。华为设备通常会在后台运行一些性能计数器(Performance Counters)来监控应用的资源使用情况,包括内存、CPU、GPU 等。
这些计数器通过 perf_event_open 系统调用实现,可以在内核级别收集性能数据。然而,当应用尝试访问这些计数器时,可能会触发一些意想不到的行为。
在我们的案例中,问题出在 Mono 运行时尝试初始化某些性能监控相关的代码路径。具体来说,Mono 的垃圾回收器(GC)在启动时会尝试配置性能计数器,以便在 GC 过程中收集性能数据。但在华为的设备上,这些配置操作可能会失败,导致内存分配异常。
2.3 解决方案
我们最终通过禁用 Mono 的 GC 性能计数器功能解决了这个问题。在 Player Settings > Player > Other Settings 中,将 Scripting Backend 设置为 IL2CPP,或者如果必须使用 Mono,则在 Advanced 选项中禁用 GC Log 和 Profiler 功能。
如果无法切换到 IL2CPP,可以在代码中通过环境变量禁用性能计数器:
using System;
using System.Diagnostics;
public class PerformanceConfig
{
public static void Configure()
{
// 禁用 GC 性能计数器
Environment.SetEnvironmentVariable("MONO_GC_PARAMS", "soft-heap-limit=0");
// 禁用其他性能监控
Environment.SetEnvironmentVariable("MONO_PROFILER", "");
Debug.Log("Performance counters disabled for Huawei compatibility");
}
}
在 Awake() 或 Start() 方法中调用这个方法:
public class GameStartup : MonoBehaviour
{
void Awake()
{
PerformanceConfig.Configure();
// 其他初始化代码
}
}
2.4 如何识别和避免类似问题
- 使用华为开发者工具:华为提供了专门的调试工具(HiSuite、Device Manager),可以监控应用的性能数据,帮助识别潜在的兼容性问题。
- 在真实设备上测试:避免只在模拟器上测试,尤其是针对华为设备,务必在真实硬件上进行测试。
- 监控内存分配模式:使用 Unity Profiler 或 Android Studio 的 Memory Profiler 监控应用的内存分配模式,识别异常的内存分配行为。
三、Mono 在 Android 上的其他常见坑
除了上述两个具体问题,Mono 在 Android 上还有一些其他常见的坑,这里一并总结,方便大家参考。
3.1 JNI 引用泄漏
JNI(Java Native Interface)引用泄漏是 Mono 在 Android 上的经典问题。当 C# 代码调用 JNI 方法时,会创建本地引用(Local References),如果这些引用没有被正确释放,会导致内存泄漏。
// 错误的做法:没有释放 JNI 引用
public void LeakExample()
{
IntPtr ptr = Marshal.StringToHGlobalAnsi("SomeString");
// 调用 JNI 方法
CallJNIFunction(ptr);
// 忘记释放 ptr,导致内存泄漏
}
// 正确的做法:释放 JNI 引用
public void SafeExample()
{
IntPtr ptr = Marshal.StringToHGlobalAnsi("SomeString");
try
{
CallJNIFunction(ptr);
}
finally
{
Marshal.FreeHGlobal(ptr); // 释放内存
}
}
3.2 线程安全问题
Mono 的 GC 和线程调度在某些情况下可能引发线程安全问题,尤其是在多线程环境下访问共享资源时。
// 错误的做法:线程不安全
private List<string> sharedList = new List<string>();
public void AddItem(string item)
{
sharedList.Add(item); // 可能引发线程安全问题
}
// 正确的做法:使用锁或线程安全集合
private object lockObj = new object();
private List<string> sharedList = new List<string>();
public void AddItemSafe(string item)
{
lock (lockObj)
{
sharedList.Add(item); // 线程安全
}
}
3.3 字符串编码问题
Android 系统默认使用 UTF-8 编码,而 Mono 在某些情况下可能使用不同的编码,导致字符串处理问题。
// 错误的做法:使用默认编码
byte[] bytes = Encoding.Default.GetBytes("SomeString");
// 正确的做法:明确指定 UTF-8 编码
byte[] bytes = Encoding.UTF8.GetBytes("SomeString");
// 或者使用 Marshal 方法
IntPtr ptr = Marshal.StringToHGlobalAnsi("SomeString");
3.4 反射性能问题
Mono 的反射实现比 .NET Framework 慢,大量使用反射可能导致性能问题。
// 错误的做法:大量使用反射
public object GetPropertyValue(object obj, string propertyName)
{
Type type = obj.GetType();
PropertyInfo prop = type.GetProperty(propertyName);
return prop.GetValue(obj);
}
// 正确的做法:使用缓存或委托
private Dictionary<Type, Dictionary<string, Func<object, object>>> propertyCache =
new Dictionary<Type, Dictionary<string, Func<object, object>>>();
public object GetPropertyValueCached(object obj, string propertyName)
{
Type type = obj.GetType();
if (!propertyCache.ContainsKey(type))
{
propertyCache[type] = new Dictionary<string, Func<object, object>>();
}
if (!propertyCache[type].ContainsKey(propertyName))
{
PropertyInfo prop = type.GetProperty(propertyName);
propertyCache[type][propertyName] = (o) => prop.GetValue(o);
}
return propertyCache[type][propertyName](obj);
}
四、给年轻开发者的建议
写到这里,我想对刚入行的开发者说几句心里话。
Mono 在 Android 上的这些问题,很多都不是因为你代码写得不好,而是因为底层平台复杂性带来的“不可避免”的坑。我见过很多年轻开发者因为遇到这些问题而自我怀疑,觉得自己不够优秀。但请记住,这些问题的解决需要经验,而经验只能通过实践积累。
我的建议是:
- 保持好奇心:遇到问题时,不要急于求成,先深入理解问题的本质。阅读官方文档、源码和相关的技术文章,往往能找到答案。
- 建立知识库:将遇到的问题和解决方案记录下来,形成一个可检索的知识库。这不仅有助于个人成长,也能帮助团队其他成员。
- 不要害怕失败:每个 Bug 都是一个学习机会。即使问题最终没有解决,排查过程本身也是宝贵的经验。
- 寻求社区帮助:不要独自挣扎,加入相关的开发者社区,分享你的问题,也帮助他人解决问题。
最后,我想说,Mono 在 Android 上的适配虽然充满挑战,但只要你愿意深入理解底层原理,这些问题都是可以解决的。希望这篇文章能给你带来一些启发,祝你在开发的道路上越走越远!
后记:写这篇文章时,我重新审视了项目中所有与 Mono 相关的代码,发现还有几个潜在的坑。看来,与 Mono 的战斗永远不会结束,但正是这些挑战,让开发工作充满了乐趣和意义。如果你也有类似的经验或问题,欢迎在评论区分享,我们一起交流讨论。