. 问题背景与现象
在近期开发 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: can't enable GNU RELRO protection for ".../libexpat.so": Out of memory
dlopen failed: can't enable GNU RELRO protection for ".../libmyso1.so": Out of memory
这些错误不仅出现在主业务库上,甚至蔓延到了诸多基础依赖库及其子依赖上。至此,排查方向彻底明朗:这并非 Unity C# 侧的调用逻辑问题,也不是简单的文件丢失,而是基础动态库在华为设备的系统装载器(Loader)阶段就遭遇了严重的兼容性失败。
3. RELRO 装载机制与底层兼容性问题
日志中反复出现的 can't enable GNU RELRO protection ... Out of memory 极具迷惑性。在排查初期,我们很容易将其误判为设备的物理内存耗尽。但事实上,这里的“Out of memory”指的是虚拟地址空间或内存映射(Memory Mapping)的分配失败。
那么另一个关键点RELRO指的是什么?RELRO(Relocation Read-Only)是 Linux/Android 下的一种内存保护机制。它要求动态链接器在加载动态库时,先完成所有的符号重定位,然后将包含重定位信息的内存页标记为“只读”。这能有效防止攻击者篡改 GOT 表进行劫持。
由于我们使用的基础库数量不少,问题产生的原因可能是:
- 碎片化装载的代价:项目中存在大量独立的小型 so 文件。每一个独立的 so 在被
dlopen时,都需要系统为其分配独立的内存空间来建立重定位表和只读保护。 - ROM 实现的差异:相比于原生 AOSP 较为宽容的装载器,鸿蒙系统的底层 Loader 实现在处理这种“海量小型库并发装载”时,可能触发了某种内部限制或碎片化瓶颈,导致无法再为新的 RELRO 保护段申请到合适的连续虚拟内存。
既然明确了症结在于“大量独立 so 文件的装载压力”,我们的解决思路就必须从构建源头入手:优化编译选项,减小动态库的体积与重定位开销。
3.1 优化原生库构建策略
此前,我们在构建基础第三方库时,仅考虑了 Android 15+ 所需的 16KB 内存页对齐问题:
# 旧的链接标志
$LINKER_FLAGS = "-Wl,-z,max-page-size=16384,-z,common-page-size=16384"
为了从根本上解决鸿蒙上的 RELRO 报错,我们对链接器和编译器参数进行了全面升级。新的 $LINKER_FLAGS 修改为:
$LINKER_FLAGS = "-Wl,-z,max-page-size=16384,-z,common-page-size=16384,--pack-dyn-relocs=android+relr,--use-android-relr-tags,--gc-sections"
新增参数的核心含义:
--pack-dyn-relocs=android+relr:这是最关键的优化。它将传统的、占用空间较大的重定位记录,压缩为更紧凑的 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-size=16384,-z,common-page-size=16384,--pack-dyn-relocs=android+relr,--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_ABI=arm64-v8a",
"-DANDROID_PLATFORM=android-29",
"-DCMAKE_FIND_ROOT_PATH=$InstallDir",
"-DCMAKE_PREFIX_PATH=$InstallDir",
"-DCMAKE_INSTALL_PREFIX=$InstallDir",
"-DCMAKE_BUILD_TYPE=Release",
"-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 ">>> 并行构建已启用"更多推荐

所有评论(0)