返回博客

ImGui + OpenGL/Vulkan Android 集成实践

背景

在 Android Root 环境下,很多时候我们需要在已有原生应用之上叠加一层调试或辅助信息。传统的 Toast / Snackbar 无法满足复杂交互需求,而编写完整 Android Activity 又过于臃肿。ImGui(Dear ImGui)作为一个轻量级即时模式 GUI 库,凭借其极低的集成成本和丰富的控件生态,成为这类场景的理想选择。

本文基于实际项目经验,记录将 ImGui 集成到 Android Native 二进制程序中的完整方案。我们的目标是在一个 su 启动的独立进程中运行 ImGui,通过图形 API 直接渲染到帧缓冲,同时捕获触摸事件实现交互。项目涉及三个核心模块:图形渲染后端、触摸输入适配、以及 Overlay 绘制层的生命周期管理。

💡 适用场景

ROOT 设备上的游戏辅助信息叠加、性能监控浮层(FPS / 温度 / 内存)、调试控制台、以及自定义交互工具。代码基于 Android 12+ 测试,理论兼容 Android 8.0+。

渲染后端

ImGui 官方提供了多个渲染后端实现(backends),在 Android 上我们主要关注两种图形 API:OpenGL ES 3.xVulkan 1.1+。两者各有优劣,选择取决于目标设备的 GPU 能力以及是否需要与其他图形栈共存。

OpenGL ES 集成方案

OpenGL ES 是 Android 上最广泛支持的图形接口。集成步骤相对成熟:

关键优化:在 Root 环境下,不建议使用 Android 的 ANativeWindow 来创建 Surface,这容易与前台 Activity 的 SurfaceFlinger 冲突。更稳健的做法是创建一个离屏 FBO,渲染完成后通过 memcpy 或 DMA-BUF 将像素数据推送到 Overlay 层。

Vulkan 集成方案

Vulkan 后端的集成复杂度更高,但带来了更好的多线程性能和更低的 CPU 开销:

对比维度 OpenGL ES Vulkan
集成难度 低,代码量 ~200 行 中高,代码量 ~500 行
兼容性 Android 4.0+ 全覆盖 Android 7.0+(部分低端设备不支持)
CPU 开销 中等 较低,多线程友好
Overlay 协作 FBO + 像素拷贝 直接导出 VkImage
调试工具 RenderDoc, APITrace RenderDoc, Vulkan Validation Layers

双后端选择策略

我们采用了运行时策略:启动时尝试初始化 Vulkan,若失败(设备不支持或驱动不稳定)自动回退到 OpenGL ES。这一策略在测试覆盖的 20+ 款设备上实现了 100% 兼容

// 伪代码:双后端自动选择
bool InitRenderer() {
    if (TryInitVulkan()) {
        g_Backend = BACKEND_VULKAN;
        return true;
    }
    LOGW("Vulkan init failed, falling back to OpenGL ES");
    if (TryInitOpenGLES()) {
        g_Backend = BACKEND_OPENGL;
        return true;
    }
    LOGE("All render backends failed");
    return false;
}

触摸输入适配

输入是集成过程中最具挑战性的环节。ImGui 需要鼠标/触摸位置和点击状态,但在 Root 环境下的独立进程中,我们无法直接使用 Android 的 InputDispatcherMotionEvent。解决方案是使用 /dev/uinput 配合 /dev/input/eventX 来劫持和转发触摸事件。

TouchManager 过检测库设计

我们设计了一个名为 TouchManager 的模块,负责三件事:

为了防止应用层检测(例如游戏反作弊系统扫描 /dev/uinput 设备),我们在 TouchManager 中实现了过检测策略:

uinput 设备冲突问题

一个常见的坑点是:当系统中有多个 uinput 设备时(例如某些 ROM 自带的虚拟键盘或手势导航),我们的 uinput 设备可能与其他服务竞争事件流。表现为触摸事件重复、坐标漂移或点击不响应。

解决方案:在 TouchManager 初始化时,通过 EVIOCGNAME 遍历所有 /dev/input/event*,建立一个白名单。只监听非 uinput 的真实触摸设备(通常含有 "touch"、"synaptics"、"fts" 等特征字符串)。注入时则创建一个独立的 uinput 设备,其 input_id 模拟常见触摸控制器的 Vendor/Product ID。

⚠️ 注意

部分游戏(如《和平精英》、《原神》)会通过检查 /proc/bus/input/devices 来探测异常输入设备。建议在注入操作完成后立即销毁 uinput 设备,减少暴露窗口。

EVIOCGNAME 设备检测

核心代码片段,展示如何通过 EVIOCGNAME 获取设备名称并分类:

#include <linux/input.h>
#include <sys/ioctl.h>
#include <fcntl.h>

bool IsTouchDevice(const char* path) {
    int fd = open(path, O_RDONLY | O_NONBLOCK);
    if (fd < 0) return false;

    char name[256] = {0};
    if (ioctl(fd, EVIOCGNAME(sizeof(name) - 1), name) < 0) {
        close(fd);
        return false;
    }
    close(fd);

    // 触摸设备通常包含这些关键词
    const char* keywords[] = {
        "touch", "synaptics", "fts", "goodix",
        "elan", "msg", "melfas", "raydium"
    };
    for (auto& kw : keywords) {
        if (strcasestr(name, kw)) return true;
    }
    return false;
}

Overlay 绘制

Overlay 层负责将 ImGui 渲染结果展示到屏幕最顶层。在 Root 设备上,有几种主流方案:

在我们的实践中,DRM/KMS 方案表现最稳定。它绕过了 Android 的图形栈理解问题,直接与显示硬件交互。关键步骤:

// DRM Overlay 简化流程
int drm_fd = open("/dev/dri/card0", O_RDWR);
drmModeRes* res = drmModeGetResources(drm_fd);

// 创建一个 dumb buffer 作为叠加层
struct drm_mode_create_dumb dumb = {0};
dumb.width = screen_w;
dumb.height = screen_h;
dumb.bpp = 32;
ioctl(drm_fd, DRM_IOCTL_MODE_CREATE_DUMB, &dumb);

// 将 ImGui 渲染的像素数据 mmap 并 memcpy
struct drm_mode_map_dumb map = {0};
map.handle = dumb.handle;
ioctl(drm_fd, DRM_IOCTL_MODE_MAP_DUMB, &map);

uint32_t* fb_data = (uint32_t*)mmap(0, dumb.size,
    PROT_READ | PROT_WRITE, MAP_SHARED,
    drm_fd, map.offset);

// 每帧拷贝 ImGui 渲染结果
memcpy(fb_data, imgui_pixels, screen_w * screen_h * 4);

常见坑点

首次启动触摸不工作

这是一个让很多人浪费数天的问题。现象是:二进制启动后,ImGui 界面正常显示,但触摸没有任何响应。原因是 /dev/input/event* 的读取时机问题——在进程刚启动时,input 设备可能尚未完成初始化,或者被 selinux 策略阻塞。

解决方案:在 TouchManager 初始化时加入 重试机制,最多尝试 10 次,每次间隔 200ms。同时检查 selinux 上下文,确保进程具有 u:r:su:s0 或类似的允许访问 input 设备的上下文。若 selinux 为 enforcing 模式,需要添加规则或临时切换为 permissive。

多线程渲染问题

ImGui 本身不是线程安全的。所有 ImGui API 调用(包括 NewFrame()、控件添加、Render())必须在同一个线程中执行。如果使用多线程进行事件轮询(例如一个线程读 input event,另一个线程做渲染),必须将事件数据通过原子队列传递到渲染线程,然后由渲染线程在 NewFrame() 之前更新 io 状态。

// 线程安全的事件传递模式
std::atomic<TouchEvent> g_PendingEvent;
std::mutex g_EventMutex;

// 事件线程
void EventThread() {
    while (running) {
        TouchEvent ev = ReadTouchEvent();
        {
            std::lock_guard<std::mutex> lock(g_EventMutex);
            g_PendingEvent = ev;
        }
    }
}

// 渲染线程(每帧)
void RenderFrame() {
    {
        std::lock_guard<std::mutex> lock(g_EventMutex);
        ImGui::GetIO().MousePos = ImVec2(g_PendingEvent.x, g_PendingEvent.y);
        ImGui::GetIO().MouseDown[0] = g_PendingEvent.pressed;
    }
    ImGui_ImplXXXX_NewFrame();
    ImGui::NewFrame();
    // ... 绘制 UI ...
    ImGui::Render();
    ImGui_ImplXXXX_RenderDrawData(ImGui::GetDrawData());
}

其他常见问题

总结

将 ImGui 集成到 Android Root 环境的 Native 进程中是一个涉及多领域知识的技术挑战——需要同时掌握图形 API、Linux input 子系统、Android 图形栈以及一定的逆向工程经验。本文介绍的方案已经在多款设备上验证通过,覆盖了从骁龙 865 到天玑 9200 的 SoC 平台。

核心收获如下:

未来可以探索的方向包括:使用 Vulkan VK_KHR_external_memory_dma_buf 实现零拷贝 Overlay 绘制,以及通过 Mmap 共享内存实现多进程间 ImGui 状态同步。对于有兴趣深入的朋友,建议先阅读 ImGui 官方的 examples/example_android_opengl3/ 示例,再结合本文的 Native 二进制场景做适配。