Unity项目适配华为鸿蒙系统的原生库加载问题排查与解决

发布时间:2026/6/30 6:16:33
Unity项目适配华为鸿蒙系统的原生库加载问题排查与解决 问题背景与现象在近期开发 AR 程序时受限于公司测试设备的匮乏笔者只能使用一台多年前的旧机型 Huawei P30 进行真机调试。相比之下我个人的 vivo X Fold5 在 AR 能力上远不及这台 P30新不如旧原因未知因此它成为了本次适配的核心测试机。值得一提的是这台 Huawei P30 已升级至鸿蒙系统。理论上由于众所周知的历史原因鸿蒙设备无法安装谷歌的 ARCore 框架。但诡异的是这台早期机型却成功安装了该框架。推测是早年 ARCore 曾对 P30 做过专项适配而在华为后续的新机型中才彻底切断了支持。这种由于历史遗留问题导致的兼容性断层确实给开发者的环境搭建带来了不少困扰。然而真正的挑战出现在应用运行阶段。我的 Unity 工程集成了部分第三方原生库以及自研的底层库。在我的 vivo X Fold5 上程序运行一切正常但在切换到这台 Huawei P30 时应用却直接崩溃并抛出了以下异常DllNotFoundException: Unable to load DLL libmyso起初笔者怀疑是打包配置遗漏或文件路径错误。但经过反复核对包体结构确认so文件均完整存在。由此笔者基本排除了常规的打包问题将焦点锁定在这大概率是一个由设备、ROM 差异引发的底层动态库装载兼容性问题。2. 原生库显式加载测试既然 C# 侧抛出了DllNotFoundException为了进一步剥离 Unity 引擎的干扰我们需要在更底层的 Java 环境中验证动态库的加载情况。最直接的手段就是绕过 Unity通过原生 Android API 进行显式加载测试。具体而言我们在 Unity 工程中挂载了一个用于探测的 C# 脚本NativeLoadProbe。该脚本会在应用启动时通过 JNI 机制调用一个自定义的 Java 类using UnityEngine; public class NativeLoadProbe : MonoBehaviour { void Start() { #if UNITY_ANDROID !UNITY_EDITOR using var cls new AndroidJavaClass(com.egova.nativecheck.NativeLoadTest); cls.CallStatic(testLoadAll); #endif } }与之对应的 Java 探针类被放置在\Assets\Plugins\Android\src\com\egova\nativecheck\NativeLoadTest.java目录下。在这个类中我们模拟了应用启动时的加载顺序依次调用System.loadLibrary()来加载核心的基础依赖库package com.egova.nativecheck; import android.util.Log; public class NativeLoadTest { private static final String TAG NativeLoadTest; public static void testLoadAll() { load(png16); load(gdal); load(libmyso); } private static void load(String name) { try { System.loadLibrary(name); Log.i(TAG, loadLibrary OK: name); } catch (Throwable t) { Log.e(TAG, loadLibrary FAIL: name , msg t.getMessage(), t); } } }随后我们通过 ADB 工具过滤并抓取底层日志./adb logcat -s NativeLoadTest Unity随着日志的滚动真正的“元凶”终于浮出水面。终端中并未出现常规的库缺失提示而是弹出了一些类似以下的报错dlopen failed: cant enable GNU RELRO protection for .../libexpat.so: Out of memory dlopen failed: cant enable GNU RELRO protection for .../libmyso1.so: Out of memory这些错误不仅出现在主业务库上甚至蔓延到了诸多基础依赖库及其子依赖上。至此排查方向彻底明朗这并非 Unity C# 侧的调用逻辑问题也不是简单的文件丢失而是基础动态库在华为设备的系统装载器Loader阶段就遭遇了严重的兼容性失败。3. RELRO 装载机制与底层兼容性问题日志中反复出现的cant enable GNU RELRO protection ... Out of memory极具迷惑性。在排查初期我们很容易将其误判为设备的物理内存耗尽。但事实上这里的“Out of memory”指的是虚拟地址空间或内存映射Memory Mapping的分配失败。那么另一个关键点RELRO指的是什么RELRORelocation Read-Only是 Linux/Android 下的一种内存保护机制。它要求动态链接器在加载动态库时先完成所有的符号重定位然后将包含重定位信息的内存页标记为“只读”。这能有效防止攻击者篡改 GOT 表进行劫持。由于我们使用的基础库数量不少问题产生的原因可能是碎片化装载的代价项目中存在大量独立的小型 so 文件。每一个独立的 so 在被dlopen时都需要系统为其分配独立的内存空间来建立重定位表和只读保护。ROM 实现的差异相比于原生 AOSP 较为宽容的装载器鸿蒙系统的底层 Loader 实现在处理这种“海量小型库并发装载”时可能触发了某种内部限制或碎片化瓶颈导致无法再为新的 RELRO 保护段申请到合适的连续虚拟内存。既然明确了症结在于“大量独立 so 文件的装载压力”我们的解决思路就必须从构建源头入手优化编译选项减小动态库的体积与重定位开销。3.1 优化原生库构建策略此前我们在构建基础第三方库时仅考虑了 Android 15 所需的 16KB 内存页对齐问题# 旧的链接标志 $LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384为了从根本上解决鸿蒙上的 RELRO 报错我们对链接器和编译器参数进行了全面升级。新的$LINKER_FLAGS修改为$LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384,--pack-dyn-relocsandroidrelr,--use-android-relr-tags,--gc-sections新增参数的核心含义--pack-dyn-relocsandroidrelr这是最关键的优化。它将传统的、占用空间较大的重定位记录压缩为更紧凑的 RELR 格式。这直接减小了 so 文件中的重定位段大小从而降低了装载时的内存映射压力。--use-android-relr-tags使用 Android 平台专用的 RELR 标签确保与安卓/鸿蒙的动态链接器完全兼容。--gc-sections启用垃圾回收机制自动剔除代码中未被引用的函数和数据段进一步缩减最终产物体积。与此同时我们在 C/C 的编译阶段也增加了相应的瘦身标志-DCMAKE_C_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_CXX_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections,其中-Oz代表极致优化体积而-fdata-sections和-ffunction-sections则是将每个数据或函数放入独立的段中配合链接器的--gc-sections实现精准的无用代码剔除。另外在修改构建参数时最好确保-DANDROID_PLATFORM的设置与 Unity 项目的配置保持一致。当前 Unity 工程设置为android-29这决定了编译时可用的 Android API 范围。如果构建脚本中的 API 级别与 Unity 不符可能会导致运行时找不到特定 API 的符号或因系统调用差异引发难以预料的崩溃。完整的 CMake 构建脚本cmake-build.ps1更多完整脚本可参看这个项目如下所示。通过这套现代化的构建管线我们生成的动态库不仅体积更小其内部的内存布局也更加紧凑# cmake-build.ps1 (修改版) param( [Parameter(Mandatory$true)][string]$PackageName, [Parameter(Mandatory$true)][string]$InstallDir, [string[]]$CMakeExtraArgs (), [bool]$ForceRebuild $false, [bool]$CleanupAfterBuild $true, [bool]$EnableParallel $true ) # 1. 从环境变量获取 NDK 路径 if (-not $env:UNITY_NDK) { Write-Error 错误环境变量 UNITY_NDK 未设置请使用 build.ps1 入口脚本运行或手动设置该变量。 exit 1 } $UNITY_NDK $env:UNITY_NDK # 再次验证路径有效性 if (-not (Test-Path $UNITY_NDK)) { Write-Error 错误UNITY_NDK 指向的路径不存在$UNITY_NDK exit 1 } Write-Host 使用 NDK: $UNITY_NDK -ForegroundColor Gray # 全局配置 $SourceBaseDir $pwd\..\Source $BuildBaseDir $pwd # 派生路径 $ZipPath $SourceBaseDir\$PackageName.zip $SourceDir $SourceBaseDir\$PackageName $BuildDir $BuildBaseDir\$PackageName $InstallMarker $InstallDir\installed\$PackageName.installed # 通用链接器标志 (Android 15 16KB Page Size RELRO 优化) $LINKER_FLAGS -Wl,-z,max-page-size16384,-z,common-page-size16384,--pack-dyn-relocsandroidrelr,--use-android-relr-tags,--gc-sections # CMake 公共参数 $CommonCMakeArgs ( -S, $SourceDir, -B, $BuildDir, -G, Ninja, -DCMAKE_TOOLCHAIN_FILE$UNITY_NDK/build/cmake/android.toolchain.cmake, -DANDROID_ABIarm64-v8a, -DANDROID_PLATFORMandroid-29, -DCMAKE_FIND_ROOT_PATH$InstallDir, -DCMAKE_PREFIX_PATH$InstallDir, -DCMAKE_INSTALL_PREFIX$InstallDir, -DCMAKE_BUILD_TYPERelease, -DCMAKE_C_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_CXX_FLAGS_RELEASE-DNDEBUG -Oz -fdata-sections -ffunction-sections, -DCMAKE_SHARED_LINKER_FLAGS_RELEASE$LINKER_FLAGS, -DCMAKE_EXE_LINKER_FLAGS_RELEASE$LINKER_FLAGS, -DCMAKE_MODULE_LINKER_FLAGS_RELEASE$LINKER_FLAGS ) # 2. 检查安装标记 if (-not $ForceRebuild -and (Test-Path $InstallMarker)) { Write-Host -ForegroundColor Green Write-Host [$PackageName] 检测到安装标记跳过构建! -ForegroundColor Green Write-Host 标记路径$InstallMarker Write-Host 如需重建请使用 -ForceRebuild $true Write-Host -ForegroundColor Green exit 0 } if ($ForceRebuild) { Write-Host [$PackageName] 强制重建模式 (ForceRebuild$ForceRebuild) if (Test-Path $InstallMarker) { Write-Host 正在移除旧的安装标记... Remove-Item -Path $InstallMarker -Force } } else { Write-Host [$PackageName] 未检测到安装标记开始构建流程... } # 3. 源码准备 (解压) if (-not (Test-Path $SourceDir)) { Write-Host [$PackageName] 源目录不存在正在解压... if (-not (Test-Path $ZipPath)) { Write-Error 错误找不到压缩包 $ZipPath exit 1 } $ExtractPath Split-Path -Path $ZipPath -Parent Add-Type -AssemblyName System.IO.Compression.FileSystem try { [System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $ExtractPath) } catch { Write-Error 解压失败: $_ exit 1 } if (-not (Test-Path $SourceDir)) { $PotentialDirs Get-ChildItem -Path $ExtractPath -Directory | Where-Object { $_.Name -like *$PackageName* -or $_.Name -like $PackageName* } if ($PotentialDirs) { $RealSource $PotentialDirs[0].FullName Rename-Item -Path $RealSource -NewName $PackageName Write-Host 自动重命名目录为 $PackageName } else { Write-Error 错误解压后仍未找到目录 $SourceDir请检查 Zip 内部结构。 exit 1 } } Write-Host [$PackageName] 解压完成 } else { Write-Host [$PackageName] 源目录已存在跳过解压 } # 4. 清理构建目录 if (Test-Path $BuildDir) { Write-Host [$PackageName] 清理旧构建目录... Remove-Item -Recurse -Force $BuildDir } New-Item -ItemType Directory -Force -Path $BuildDir | Out-Null # 5. 配置 CMake Write-Host [$PackageName] 开始配置 CMake... $AllCMakeArgs $CommonCMakeArgs $CMakeExtraArgs cmake AllCMakeArgs if ($LASTEXITCODE -ne 0) { Write-Error [$PackageName] CMake 配置失败! exit 1 } # 6. 构建与安装 Write-Host [$PackageName] 开始构建... $BuildArgs (--build, $BuildDir) if ($EnableParallel) { $BuildArgs --parallel Write-Host 并行构建已启用 } cmake BuildArgs if ($LASTEXITCODE -ne 0) { Write-Error [$PackageName] 构建失败! exit 1 } Write-Host [$PackageName] 开始安装... cmake --build $BuildDir --target install if ($LASTEXITCODE -ne 0) { Write-Error [$PackageName] 安装失败! exit 1 } # 7. 生成安装标记 try { $MarkerDir Split-Path -Path $InstallMarker -Parent if (-not (Test-Path $MarkerDir)) { New-Item -ItemType Directory -Force -Path $MarkerDir | Out-Null } $Timestamp Get-Date -Format yyyy-MM-dd HH:mm:ss Set-Content -Path $InstallMarker -Value Installed on $Timestamp via cmake-build.ps1 (Success) Write-Host [$PackageName] 安装标记已生成$InstallMarker } catch { Write-Warning 警告无法生成安装标记文件 ($_) } # 8. 清理 (可选) if ($CleanupAfterBuild) { Write-Host [$PackageName] 正在清理临时文件... if (Test-Path $SourceDir) { Remove-Item -Recurse -Force $SourceDir } if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir } Write-Host [$PackageName] 清理完成 } else { Write-Host [$PackageName] 保留临时文件 } Write-Host -ForegroundColor Green Write-Host [$PackageName] Build completed successfully! -ForegroundColor Green Write-Host 输出目录$InstallDir Write-Host -ForegroundColor Green采用这套现代化构建策略后我们生成的 so 文件不仅整体体积显著缩小其内部的重定位表也被高度压缩。这使得鸿蒙系统的 Loader 能够轻松完成内存映射与只读保护的建立从而彻底规避了令人头疼的 RELRO 装载异常。3.2 策略性采用静态库尽管通过优化链接器标志如--pack-dyn-relocs能显著缓解装载压力但在实际适配中我们发现少数底层库即便经过瘦身依然会在鸿蒙设备上触发Out of memory的 RELRO 报错。针对这一顽固问题有两种办法关闭 RELRO 保护通过在链接时添加-Wl,-z,norelro强制关闭只读重定位段。改用静态库集成将这些“问题库”从动态链接改为静态链接。这里更推荐择第二种方案。因为 RELRO重定位只读是一种重要的安全机制它能防止攻击者通过篡改全局偏移表GOT来劫持程序控制流。如果为了适配而关闭 RELRO即使用norelro虽然能解决内存映射失败的问题但会严重降低应用的抗攻击能力。在鸿蒙或安卓高版本系统中这种做法可能导致应用被安全审计拦截或者面临更高的运行时风险。因此为了在兼容性与安全性之间取得平衡最佳实践是将那些容易触发装载异常的底层库改为静态库Static Library形式进行集成。其实在原生开发中“上层业务使用动态库底层依赖使用静态库”是一种被广泛推崇的架构策略原因如下对于底层库推荐静态链接消除装载碎片底层库如libpng,zlib,expat等通常体积小、数量多。如果以动态库.so形式存在每一个都会产生独立的装载开销和 RELRO 内存映射需求。将其改为静态库.a在链接阶段直接合并进上层动态库可以彻底消除这些底层模块的独立装载过程从而完美规避鸿蒙系统的装载器限制。接口稳定性底层库的 ABI 接口通常非常稳定很少需要像插件一样热更新因此静态链接不会带来维护上的麻烦。对于上层业务库推荐动态链接热更新与模块化上层业务逻辑复杂可能需要通过动态加载来实现热修复或插件化。体积控制上层库体积较大如果静态链接会导致主程序包体急剧膨胀。在本次适配实践中我们将 2~3 个频繁报错的底层基础库如expat从动态库修改为静态库并在链接主业务库时将它们“打包”进去。经过这一调整这些库不再作为独立的so文件出现在文件系统中也就不再触发鸿蒙系统的dlopen装载流程。这就保证了在保留 RELRO 安全保护的前提下彻底解决了cant enable GNU RELRO protection ... Out of memory的崩溃问题。3.3 符号可见性与导出在构建我们自己的编写的原生库时通常会启用-fvisibilityhidden编译选项旨在将库的内部符号隐藏起来仅暴露必要的接口。这一举措不仅能减少动态链接的开销还能有效避免符号冲突。然而这一优化也带来了一个极易被忽视的问题如果未显式标记导出接口所有符号将默认变为“不可见”导致上层应用如 Unity C# 侧无法定位到对应的函数入口。具体来说就是在实施了隐藏可见性优化后如果未正确配置导出宏应用在运行时会抛出类似以下的异常C# 侧报错EntryPointNotFoundExceptionNative 侧日志dlopen failed: cannot locate symbol xxx这通常会让开发者误以为是链接阶段遗漏了库文件但事实上问题的根源在于符号的可见性Visibility被编译器“吃掉”了。在 Linux/Android (GCC/Clang) 编译器中符号的默认可见性是default这意味着该符号可以被外部程序引用。而-fvisibilityhidden选项会将所有未显式标记的符号降级为hidden级别。因此仅仅定义一个空的宏如#define TERRAIN_API是不够的。在鸿蒙/安卓平台上我们必须显式地使用__attribute__((visibility(default)))来“对抗”编译器的隐藏规则强制将特定接口导出。为了解决这一问题我们需要重构头文件中的导出宏定义。以下是修正后的标准实现CMakeLists.txt 配置在 Release 模式下开启隐藏可见性同时确保 Debug 模式下保持默认以便调试。target_compile_options(${PROJECT_NAME} PRIVATE $$CONFIG:Release: -DNDEBUG -Oz -fdata-sections -ffunction-sections -fvisibilityhidden # 隐藏默认符号 -fvisibility-inlines-hidden # 隐藏内联函数 )头文件.h导出宏定义修正后的宏定义重点在于 Android/Linux 平台必须显式指定visibility(default)。#pragma once #ifdef _WIN32 #ifdef TERRAIN_EXPORTS #define TERRAIN_API __declspec(dllexport) #else #define TERRAIN_API __declspec(dllimport) #endif #elif defined(__ANDROID__) || defined(__linux__) // 关键修复显式声明符号为默认可见防止被 -fvisibilityhidden 影响 #define TERRAIN_API __attribute__((visibility(default))) #else #define TERRAIN_API #endif除了符号可见性C 与 C# 的互操作P/Invoke还涉及调用约定Calling Convention的匹配问题。使用extern C防止 C 名称修饰如果导出的接口是 C 类编译器会对函数名进行“名称修饰”Name Mangling导致 C# 侧无法通过原生名称找到函数。建议将导出接口包裹在extern C块中或者直接使用 C 语言风格接口。统一调用约定为Cdecl在 C# 的DllImport声明中务必显式指定调用约定。对于 C/C 动态库通常应使用CallingConvention.Cdecl以避免栈溢出或参数传递错误。[DllImport(mylib, CallingConvention CallingConvention.Cdecl)] public static extern int MyFunction(int param);通过上述修正我们既享受了-fvisibilityhidden带来的性能与安全性提升又确保了关键接口能被 Unity C# 侧正确调用彻底解决了符号找不到的顽疾。