Android NDK开发之统计到未被回收的图片

知道了创建的图片信息是第一步,更重要的是知道哪些图片没有被及时回收。

经常遇到的图片泄漏问题:手动 decode 的 Bitmap 没有及时调用 recycle,导致反复进入页面内存不停上涨,最终导致功能异常。

在 Android 不同版本上,Bitmap 对象的释放流程有所不同:

  1. Android 8.0 及以前版本:调用 Java Bitmap 的 recycle 方法通过 JNI 调用只是释放了引用,图片的像素数据所占内存需要等待 GC 执行时才释放
  2. 从 8.0 开始,会直接释放掉 native 内存

两者的共同点是在执行后Java Bitmap的mRecycled状态会变为true。因此我们可以通过轮训Bitmap对象的mRecycled属性来判断这个图片是否被回收。

完整代码:

kotlin 复制代码
// ===== BitmapProxy.cpp =====
// 这个文件包含:
// 1. create_bitmap_proxy() --- 代理函数,hook 生效后替代原始 createBitmap 被调用
// 2. BitmapProxy 类的所有方法实现
// 3. 后台轮询线程的逻辑

#include "BitmapProxy.h"

// shadowhook.h 提供 ShadowHook 的 API:
//   shadowhook_hook_sym_name() --- 按符号名 hook
//   shadowhook_unhook()        --- 取消 hook
//   SHADOWHOOK_STACK_SCOPE()   --- 防止递归调用的宏
//   SHADOWHOOK_CALL_PREV()     --- 通过 stub 调用原始函数
#include "shadowhook.h"

// Android NDK 的 Bitmap 操作 API,提供:
//   AndroidBitmap_getInfo() --- 获取 Bitmap 的宽、高、格式、stride
#include <android/bitmap.h>

// ===== 全局变量 =====

// shadowhook_hook_sym_name 返回的 stub 指针
// 保存它是为了之后能 unhook(取消 hook)和通过 SHADOWHOOK_CALL_PREV 调用原始函数
static void *stub = nullptr;

// ===== 辅助函数 =====

/**
 * 获取当前时间戳,单位毫秒。
 *
 * gettimeofday 是 POSIX 系统调用,把当前时间写入 timeval 结构体:
 *   tv_sec:  秒(从 1970-01-01 开始)
 *   tv_usec: 微秒(1 秒 = 1,000,000 微秒)
 * 返回值 = 秒 * 1000 + 微秒 / 1000,得到毫秒数。
 */
static long long get_current_time_millis() {
    struct timeval tv{};    // {} 是 C++ 的值初始化语法,会把结构体的所有字段初始化为 0
    gettimeofday(&tv, nullptr);   // & 是取地址运算符,把 tv 的地址传给函数
    return (long long) tv.tv_sec * 1000 + tv.tv_usec / 1000;
}

// ===== 代理函数 =====

/**
 * 代理函数,签名必须和被 hook 的原始函数完全一致。
 *
 * 原始函数签名(Android 8.0+):
 *   jobject createBitmap(JNIEnv *env, void *bitmap,
 *                        int bitmap_create_flags,
 *                        jbyteArray nine_patch_chunk,
 *                        jobject nine_patch_insets,
 *                        int density)
 *
 * Hook 之后,所有对原始 createBitmap 的调用都会被重定向到这个函数。
 * 这个函数做的事情:
 *   1. 调用原始函数创建 Bitmap → 拿到 Java Bitmap 对象
 *   2. 获取 Bitmap 的信息(尺寸、格式等)
 *   3. 把信息记录到追踪列表中
 *   4. 把 Bitmap 对象原样返回给调用方
 *
 * 参数说明:
 *   JNIEnv *env              : JNI 环境指针,当前线程的函数表
 *   void *bitmap             : C++ 层的 native Bitmap 指针(Skia 对象)
 *   int bitmap_create_flags  : 创建标志位
 *   jbyteArray nine_patch_chunk : .9.png 的 chunk 数据
 *   jobject nine_patch_insets   : .9.png 的 padding 信息
 *   int density              : 屏幕密度
 */
jobject create_bitmap_proxy(JNIEnv *env, void *bitmap,
                            int bitmap_create_flags, jbyteArray nine_patch_chunk,
                            jobject nine_patch_insets, int density) {

    // ----- 第 1 步:声明栈作用域,防止递归 -----
    // SHADOWHOOK_STACK_SCOPE() 在当前线程的调用栈上做一个标记
    // 如果这个函数被递归调用(即原始函数又触发了 hook),ShadowHook 会检测到并直接跳过
    // 不加这个宏的话,如果 bitmap 创建过程中又创建了另一个 bitmap,就会无限递归直到栈溢出
    SHADOWHOOK_STACK_SCOPE();

    // ----- 第 2 步:调用原始 createBitmap,真正创建 Bitmap -----
    // SHADOWHOOK_CALL_PREV 通过 stub 调用原始函数,绕过 hook
    // 第一个参数 create_bitmap_proxy 用于 ShadowHook 内部查找对应的 stub
    // 后面 6 个参数原封不动传给原始函数
    // 返回的 bitmap_obj 是 Java 层 android.graphics.Bitmap 对象的 JNI 引用(jobject)
    jobject bitmap_obj = SHADOWHOOK_CALL_PREV(
            create_bitmap_proxy,
            env, bitmap, bitmap_create_flags,
            nine_patch_chunk, nine_patch_insets, density);

    // ----- 第 3 步:检查返回值 -----
    // 如果原始函数返回 nullptr,说明 Bitmap 创建失败了(比如内存不足)
    if (bitmap_obj == nullptr) {
        LOGD("create_bitmap_proxy: create failed, skipping record");
        return bitmap_obj;  // 直接返回 nullptr 给调用方
    }

    // ----- 第 4 步:使用 NDK API 获取 Bitmap 信息 -----
    // AndroidBitmapInfo 是 NDK 提供的结构体,包含:
    //   width:  宽度(像素)
    //   height: 高度(像素)
    //   stride: 每行的字节数(可能因内存对齐而 > width * 每像素字节数)
    //   format: 像素格式(如 ANDROID_BITMAP_FORMAT_RGBA_8888 等)
    //
    // AndroidBitmap_getInfo 比用 JNI 反射调用 getWidth()/getHeight() 更高效:
    //   - 不需要查找 methodID
    //   - 是直接的 C 函数调用,没有 JNI 跨语言开销
    AndroidBitmapInfo bitmapInfo{};  // {} = 初始化为零
    int ret = AndroidBitmap_getInfo(env, bitmap_obj, &bitmapInfo);

    if (ret < 0) {
        // 获取信息失败(极少见的情况,比如传入了非 Bitmap 对象)
        LOGD("create_bitmap_proxy: AndroidBitmap_getInfo failed, ret=%d", ret);
        return bitmap_obj;
    }

    // 计算实际分配的内存大小
    // stride(每行字节数)* height(行数)= 像素数据占用的总字节数
    // 注意要用 stride 而不是 width * 每像素字节数,因为 stride 包含了内存对齐的填充字节
    int64_t allocSize = (int64_t) bitmapInfo.stride * bitmapInfo.height;

    LOGD("create_bitmap_proxy: w=%d h=%d stride=%d format=%d alloc=%lld bytes (%.2f KB)",
         bitmapInfo.width, bitmapInfo.height, bitmapInfo.stride, bitmapInfo.format,
         (long long) allocSize, (float) allocSize / 1024.0f);

    // ----- 第 5 步:读取 mNativePtr 字段 -----
    // mNativePtr 是 android.graphics.Bitmap 的内部字段(类型 long)
    // 它保存着 native 层 Skia Bitmap 对象的指针地址
    // 用它作为 Bitmap 的唯一标识(类似于身份证号)
    // 这里我们用 ShadowHook 已经提供的 native_ptr 参数,两者实际是同一个值
    jlong nativePtr = reinterpret_cast<jlong>(bitmap);
    // reinterpret_cast: C++ 的类型转换运算符,把 void* 转成 jlong(即 int64_t)
    // 注意:不改变内存中的值,只是告诉编译器"把这个指针当整数看待"

    // ----- 第 6 步:记录到追踪列表 -----
    // 检查全局 BitmapProxy 实例是否存在(正常情况下一定存在,除非还没初始化)
    if (g_bitmapProxy != nullptr) {
        // -> 运算符:通过指针访问对象的成员函数
        // 等价于 (*g_bitmapProxy).onBitmapCreated(...)
        g_bitmapProxy->onBitmapCreated(
                env, bitmap_obj,
                bitmapInfo.width, bitmapInfo.height,
                bitmapInfo.stride, bitmapInfo.format,
                nativePtr);
    }

    // ----- 第 7 步:返回 Bitmap 对象给调用方 -----
    // 调用方对此完全无感知------它拿到的 Bitmap 和没 hook 时一模一样
    return bitmap_obj;
}

// ===== BitmapProxy 成员函数实现 =====
// 类名::函数名 是 C++ 的成员函数定义语法
// :: 是作用域解析运算符,"BitmapProxy::startProxy" 意思是 "BitmapProxy 类的 startProxy 方法"

/**
 * 启动整个监控系统:
 *   1. 根据 Android 版本选择正确的 so 文件和函数符号
 *   2. 执行 PLT hook
 *   3. 启动后台追踪(缓存 JNI ID + 开启轮询线程)
 */
void BitmapProxy::startProxy(JavaVM *jvm) {
    // android_get_device_api_level() 是 ShadowHook 提供的函数,返回当前设备的 API Level
    int api_level = android_get_device_api_level();

    // 根据 API Level 选择要 hook 的 .so 文件
    // auto 是 C++11 的类型推导关键字,编译器自动推断变量类型
    // 这里 auto 推导为 const char*
    // 三元运算符 ? : = 条件 ? 值1 : 值2
    auto so = api_level > API_LEVEL_10_0
              ? BITMAP_CREATE_SYMBOL_SO_RUNTIME_AFTER_10    // API > 29 → libhwui.so
              : BITMAP_CREATE_SYMBOL_SO_RUNTIME;            // API ≤ 29 → libandroid_runtime.so

    // 根据 API Level 选择要 hook 的函数符号名
    // Android 8.0 重构了 Bitmap 相关代码,C++ 函数名发生了变化
    auto symbol = api_level >= API_LEVEL_8_0
                  ? BITMAP_CREATE_SYMBOL_RUNTIME       // API ≥ 26 → 新符号
                  : BITMAP_CREATE_SYMBOL_BEFORE_8;     // API < 26 → 旧符号

    LOGD("startProxy: api_level=%d, so=%s, symbol=%s", api_level, so, symbol);

    // 执行 PLT Hook
    // shadowhook_hook_sym_name 参数:
    //   so      : 目标 .so 文件名
    //   symbol  : 目标函数符号名
    //   (void *) create_bitmap_proxy : 代理函数的地址(转成 void* 指针)
    //   nullptr : 可选的回调,这里不需要
    // 返回值 stub : 一个不透明的指针,用于后续 unhook 和 SHADOWHOOK_CALL_PREV
    stub = shadowhook_hook_sym_name(so, symbol, (void *) create_bitmap_proxy, nullptr);

    if (stub != nullptr) {
        LOGD("startProxy: hook success");
    } else {
        LOGW("startProxy: hook failed!");
        return;
    }

    // Hook 成功后,启动 Bitmap 追踪(缓存 JNI ID + 开启后台轮询线程)
    startTracking(jvm);
}

/**
 * 停止整个监控系统:
 *   1. 取消 Hook
 *   2. 停止后台轮询线程
 */
void BitmapProxy::stopProxy() {
    // 先取消 hook ------ 之后新创建的 Bitmap 不再经过代理函数
    if (stub != nullptr) {
        shadowhook_unhook(stub);
        stub = nullptr;
        LOGD("stopProxy: unhook done");
    }

    // 停止后台追踪线程
    stopTracking();
}

// ===== 追踪记录的管理 =====

/**
 * 由代理函数调用,记录新创建的 Bitmap。
 *
 * 这里做了两件事:
 *   1. 创建一个弱全局引用(不阻止 GC)
 *   2. 把记录加入 m_records 列表
 *
 * 关于 NewWeakGlobalRef:
 *   - NewGlobalRef:    强全局引用,只要不手动释放,GC 永远不会回收这个对象
 *                      如果一直不释放就会造成内存泄漏 → 监控工具反而制造了泄漏!
 *   - NewWeakGlobalRef: 弱全局引用,不会阻止 GC
 *                       对象被 GC 后,弱引用自动变成 NULL(可以通过 IsSameObject 检测)
 *   - 所以我们用弱引用,既能在对象存活时访问它,又不会阻止它被正常回收
 */
void BitmapProxy::onBitmapCreated(JNIEnv *env, jobject bitmap,
                                  jint width, jint height, jint stride, jint format,
                                  jlong native_ptr) {

    // NewWeakGlobalRef: 创建弱全局引用
    // 参数是局部引用(只在当前 JNI 调用中有效),返回的全局引用可以跨线程使用
    jweak weakRef = env->NewWeakGlobalRef(bitmap);

    // 如果创建弱引用失败(极少见),放弃这次记录
    if (weakRef == nullptr) {
        LOGW("onBitmapCreated: NewWeakGlobalRef failed");
        return;
    }

    // 获取创建时间戳
    long long now = get_current_time_millis();

    // ----- 临界区开始 -----
    // std::lock_guard 是 RAII 风格的锁包装器:
    //   构造时自动调用 m_mutex.lock()(加锁)
    //   析构时自动调用 m_mutex.unlock()(解锁)
    //   即使中间发生异常,析构也会自动执行,不会死锁
    //   这比手动 lock()/unlock() 安全得多
    {
        std::lock_guard<std::mutex> lock(m_mutex);

        // push_back: 往 vector 末尾追加一个元素
        // { ... } 是 C++11 的统一初始化语法,直接构造一个 BitmapRecord 并加入 vector
        m_records.push_back({
                                    weakRef,      // java_bitmap_ref
                                    native_ptr,   // native_ptr
                                    width,        // width
                                    height,       // height
                                    stride,       // stride
                                    format,       // format
                                    now           // create_time
                            });
    }
    // ----- 临界区结束(lock_guard 析构,自动解锁)-----

    LOGD("onBitmapCreated: recorded, total=%zu", m_records.size());
    // %zu 是 size_t(无符号整数类型,vector::size() 的返回类型)的格式说明符
}

// ===== 后台轮询 =====

/**
 * 启动追踪系统:
 *   1. 获取当前线程的 JNIEnv,缓存 isRecycled() 方法的 methodID
 *      (methodID 是跨线程有效的,可以在任何线程使用)
 *   2. 创建一个新的后台线程,循环检查哪些 Bitmap 已被回收
 */
void BitmapProxy::startTracking(JavaVM *jvm) {
    m_jvm = jvm;  // 保存 JavaVM 指针,后台线程需要用它获取 JNIEnv

    // ----- 缓存 JNI 方法 ID -----
    // JNI 中,类的 methodID 和 fieldID 在进程内是全局有效的,跨线程安全
    // 提前缓存好,避免轮询线程每次都要重新查找(省去 FindClass + GetMethodID 的开销)
    JNIEnv *env;
    // AttachCurrentThread: 如果当前线程已经附加到 JVM,返回已有的 JNIEnv
    //                      如果还没附加,会自动附加
    // 这里当前线程是主线程,已经附加了,所以直接返回
    jvm->AttachCurrentThread(&env, nullptr);

    // FindClass: 根据全限定类名查找类,返回局部引用
    // 注意 JNI 中的类名用 / 而不是 . 分隔
    jclass localBitmapClass = env->FindClass("android/graphics/Bitmap");
    if (localBitmapClass == nullptr) {
        LOGW("startTracking: FindClass failed");
        return;
    }

    // NewGlobalRef: 把局部引用升级为全局引用
    // 局部引用只在当前 JNI 调用帧中有效,函数返回后就失效
    // 全局引用一直有效(直到手动 DeleteGlobalRef),可以从其他线程访问
    m_bitmapClass = (jclass) env->NewGlobalRef(localBitmapClass);
    env->DeleteLocalRef(localBitmapClass);  // 释放局部引用,避免引用表溢出

    // GetMethodID: 获取方法的 ID
    // 参数: 类, 方法名, 方法签名
    // 方法签名 "()Z" 的含义:
    //   ()  = 无参数
    //   Z   = 返回 boolean(Z 是 JNI 中 boolean 的类型码)
    m_isRecycledMethod = env->GetMethodID(m_bitmapClass, "isRecycled", "()Z");
    if (m_isRecycledMethod == nullptr) {
        LOGW("startTracking: GetMethodID(isRecycled) failed");
        return;
    }

    LOGD("startTracking: JNI method IDs cached");

    // ----- 启动后台轮询线程 -----
    m_polling.store(true);  // atomic 写操作,设置运行标志为 true

    // 创建 std::thread 对象
    // 构造函数的参数:一个可调用对象(这里用 lambda 表达式)
    // [this] 是 lambda 的捕获列表,把 this 指针传进去,lambda 内部就能访问成员变量
    // () 是参数列表(这里没有参数)
    // { ... } 是函数体
    m_pollThread = std::thread([this]() {
        this->pollLoop();  // 在后台线程中执行 pollLoop
    });

    LOGD("startTracking: poll thread started, interval=%d seconds", CHECK_INTERVAL_SECONDS);
}

/**
 * 停止追踪系统:
 *   1. 通过 atomic 标志通知轮询线程退出
 *   2. 等待线程结束(join)
 *   3. 清理缓存的 JNI 全局引用
 */
void BitmapProxy::stopTracking() {
    // 第一步:通知轮询线程退出
    m_polling.store(false);

    // 第二步:等待后台线程结束
    // join(): 阻塞当前线程,直到 m_pollThread 执行完毕
    // joinable(): 检查线程是否可以 join(线程是否还在运行且没有被 join 过)
    // 不 join 就析构 thread 对象会导致程序 crash(std::terminate)
    if (m_pollThread.joinable()) {
        m_pollThread.join();
    }
    LOGD("stopTracking: poll thread stopped");

    // 第三步:清理 JNI 全局引用
    // 全局引用不会自动释放,必须手动调用 DeleteGlobalRef
    // 否则会一直占用 JNI 引用表空间,最终导致崩溃
    JNIEnv *env;
    if (m_jvm->AttachCurrentThread(&env, nullptr) == 0) {
        // 清理弱引用:遍历所有记录,释放每个 Bitmap 的弱引用
        for (auto &record : m_records) {
            // & 在这里是引用符号,auto& 表示 record 是 m_records 中元素的引用(别名)
            // 不用 & 的话会拷贝一份,修改副本不会影响原数据
            env->DeleteWeakGlobalRef(record.java_bitmap_ref);
        }
        m_records.clear();  // 清空 vector

        // 释放缓存的 Bitmap 类全局引用
        if (m_bitmapClass != nullptr) {
            env->DeleteGlobalRef(m_bitmapClass);
            m_bitmapClass = nullptr;
        }
    }

    LOGD("stopTracking: cleaned up");
}

/**
 * 后台轮询线程的主循环。
 *
 * 执行流程:
 *   1. 把当前线程附加到 JVM(获取 JNIEnv)
 *   2. 循环(每次间隔 CHECK_INTERVAL_SECONDS 秒):
 *      a. 加锁复制当前记录列表
 *      b. 遍历每条记录:
 *         - 用 IsSameObject 检查弱引用是否还可达(对象是否被 GC 了)
 *         - 如果可达,用 NewLocalRef 获取临时强引用
 *         - 调用 isRecycled() 检查是否主动回收了
 *         - 如果已回收或已 GC → 从列表中移除
 *         - 如果都未回收 → 保留(这就是泄漏的可疑对象)
 *      c. 用过滤后的列表替换原列表
 *      d. 解锁
 *   3. 线程退出时从 JVM 分离
 */
void BitmapProxy::pollLoop() {
    // ----- 附加当前线程到 JVM -----
    // 这个线程是 C++ 创建的,JVM 还不认识它
    // AttachCurrentThread 把线程注册到 JVM,并返回一个 JNIEnv 指针
    // 有了 JNIEnv 才能调用 Java 方法
    JNIEnv *env = nullptr;
    if (m_jvm->AttachCurrentThread(&env, nullptr) != 0) {
        LOGW("pollLoop: AttachCurrentThread failed");
        return;
    }

    LOGD("pollLoop: started");

    // ----- 主循环 -----
    // while (m_polling.load()): atomic 读操作,线程安全地读取标志位
    while (m_polling.load()) {

        // 分段 sleep:每秒醒来一次检查 m_polling 标志
        // 如果用 sleep(CHECK_INTERVAL_SECONDS),停止时最多要等 5 秒才能响应
        // 分段 sleep 让线程能更快地响应停止信号
        for (int i = 0; i < CHECK_INTERVAL_SECONDS && m_polling.load(); i++) {
            // std::chrono::seconds(1) = 1 秒的时间长度
            // sleep_for 让当前线程休眠指定时间
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }

        // 可能在 sleep 期间被通知退出了,再检查一次
        if (!m_polling.load()) {
            break;
        }

        // ----- 检查所有记录 -----
        {
            // 加锁,保护 m_records
            std::lock_guard<std::mutex> lock(m_mutex);

            // 如果列表是空的,跳过本轮检查
            // empty() 返回 vector 是否为空
            if (m_records.empty()) {
                continue;  // continue 跳到 while 循环的下一次迭代
            }

            // 创建一个新列表,存放本轮检查后"仍未被回收"的记录
            std::vector<BitmapRecord> surviving;

            // 遍历 m_records 中的所有记录
            // range-based for 循环(C++11):依次取出 m_records 中的每个元素,赋值给 record
            // auto& : 引用类型,避免拷贝整个 BitmapRecord
            for (auto &record : m_records) {

                // ----- 检查第 1 关:对象是否被 GC 回收了? -----
                // IsSameObject(obj1, obj2): 比较两个 JNI 引用是否指向同一个对象
                // 特殊用法:IsSameObject(weakRef, nullptr)
                //   返回 JNI_TRUE  → 弱引用指向的对象已被 GC 回收(或从未有效过)
                //   返回 JNI_FALSE → 对象还存在,可以继续访问
                jboolean isGarbageCollected = env->IsSameObject(record.java_bitmap_ref, nullptr);
                // jboolean 是 JNI 的布尔类型,本质是 unsigned char

                if (isGarbageCollected == JNI_TRUE) {
                    // 对象已经被 GC 回收了 → 不需要再追踪这条记录
                    // DeleteWeakGlobalRef: 释放弱引用,归还 JNI 引用表空间
                    env->DeleteWeakGlobalRef(record.java_bitmap_ref);
                    continue;  // 不加入 surviving 列表 = 从追踪中移除
                }

                // ----- 检查第 2 关:用户是否主动调用了 recycle()? -----
                // 通过了第 1 关说明对象还活着,现在需要暂时获取一个强引用来调用方法

                // NewLocalRef: 从弱引用创建一个局部强引用
                // 这个局部引用会暂时阻止对象被 GC(在我们的检查期间)
                // 返回 nullptr 说明弱引用在此期间失效了(极端情况)
                jobject localRef = env->NewLocalRef(record.java_bitmap_ref);
                if (localRef == nullptr) {
                    // 弱引用刚好在这时失效(对象被 GC 了)
                    env->DeleteWeakGlobalRef(record.java_bitmap_ref);
                    continue;
                }

                // 调用 Bitmap.isRecycled() 方法
                // CallBooleanMethod: JNI 中调用返回 boolean 的 Java 方法
                // 参数:对象引用, 方法 ID
                jboolean isRecycled = env->CallBooleanMethod(localRef, m_isRecycledMethod);

                if (isRecycled == JNI_TRUE) {
                    // 用户调了 recycle() → Bitmap 已主动释放
                    // 清理引用后从追踪中移除
                    env->DeleteLocalRef(localRef);
                    env->DeleteWeakGlobalRef(record.java_bitmap_ref);
                    continue;
                }

                // ----- 两关都没过 → 这是一个未被回收的 Bitmap! -----
                // 把它保留在新列表中,继续追踪
                surviving.push_back(record);
                // 注意:这里 record 的 java_bitmap_ref 已经在上面通过 NewLocalRef 验证过是有效的
                // surviving 中的记录保留了弱引用,下次循环还会再检查

                // 释放局部引用(我们用完了,不需要一直持有强引用)
                env->DeleteLocalRef(localRef);
            }

            // 用过滤后的列表替换原列表
            // == 比较的是两个 vector 的内容
            // 如果数量没变化,说明没有 Bitmap 在这轮被回收
            if (surviving.size() != m_records.size()) {
                m_records = surviving;  // 替换
            }
        }
        // 锁在这里释放(lock_guard 离开作用域时自动析构)

        // 如果有未回收的 Bitmap,打印警告日志
        if (!m_records.empty()) {
            long long totalSize = getUnrecycledSize();
            LOGW("pollLoop: %zu unrecycled bitmaps, total size=%lld bytes (%.2f KB)",
                 m_records.size(), totalSize, (float) totalSize / 1024.0f);
        }
    }

    // ----- 线程退出,从 JVM 分离 -----
    // DetachCurrentThread: 告诉 JVM 这个线程不再需要 JNI 访问
    // 如果不 detach,JVM 会一直保留这个线程的 JNI 资源
    m_jvm->DetachCurrentThread();
    LOGD("pollLoop: stopped");
}

// ===== 对外查询接口 =====

/**
 * 获取当前未被回收的 Bitmap 数量。
 * 这个方法是线程安全的(加锁读取)。
 *
 * 返回值: 仍在追踪列表中的 Bitmap 数量
 *         这些 Bitmap 满足两个条件:
 *         1. 没有被 GC 回收(弱引用还可达)
 *         2. 没有调用过 recycle()
 */
int BitmapProxy::getUnrecycledCount() {
    // 加锁保护(因为代理函数可能在另一个线程中同时修改 m_records)
    std::lock_guard<std::mutex> lock(m_mutex);
    return (int) m_records.size();  // static_cast 也可以,但 C 风格强转在此处更简洁
}

/**
 * 计算未被回收的 Bitmap 的总内存占用。
 *
 * 注意:实际内存 = stride * height(不是 width * height * 每像素字节数)
 * 因为 stride 包含了内存对齐的填充字节。
 */
long long BitmapProxy::getUnrecycledSize() {
    std::lock_guard<std::mutex> lock(m_mutex);

    long long totalBytes = 0;
    for (auto &record : m_records) {
        // stride(每行字节数)* height(行数)= 像素数据总字节数
        totalBytes += (long long) record.stride * record.height;
    }
    return totalBytes;
}

/**
 * 将所有未被回收的 Bitmap 信息打印到 logcat。
 * 每条记录包括:
 *   - native 指针地址
 *   - 尺寸(宽 x 高)
 *   - 内存占用
 *   - 格式
 *   - 存活时间(当前时间 - 创建时间)
 *
 * 这个方法适合在开发调试时手动调用,快速查看有哪些 Bitmap 没有被释放。
 */
void BitmapProxy::dumpUnrecycled(JNIEnv *env) {
    std::lock_guard<std::mutex> lock(m_mutex);

    if (m_records.empty()) {
        LOGD("dumpUnrecycled: all bitmaps recycled!");
        return;
    }

    long long now = get_current_time_millis();

    // 打印概览
    long long totalSize = 0;
    for (auto &record : m_records) {
        totalSize += (long long) record.stride * record.height;
    }
    LOGW("===== Unrecycled Bitmaps: %zu, total size: %lld bytes (%.2f KB) =====",
         m_records.size(), totalSize, (float) totalSize / 1024.0f);

    // 逐条打印详情
    int index = 0;
    for (auto &record : m_records) {
        // 检查弱引用是否还可达
        jboolean reachable = env->IsSameObject(record.java_bitmap_ref, nullptr);

        // 计算存活时间
        long long aliveSeconds = (now - record.create_time) / 1000;

        // 打印单条记录
        // 格式说明:
        //   nativePtr=0x%llx: 以十六进制打印指针地址
        //   w/h: 宽高
        //   stride: 行字节数
        //   fmt: 像素格式
        //   alive: 存活时长
        //   reachable: 弱引用是否可达(y=可达, n=已被GC)
        LOGW("  [%d] nativePtr=0x%llx, %dx%d, stride=%d, fmt=%d, "
             "alloc=%lld bytes, alive=%lld s, reachable=%c",
             index,
             (long long) record.native_ptr,
             record.width, record.height,
             record.stride, record.format,
             (long long) record.stride * record.height,
             aliveSeconds,
             reachable == JNI_TRUE ? 'n' : 'y');
        // reachable 反过来:IsSameObject == JNI_TRUE 表示已经是 nullptr(被 GC 了)
        // 这里打 'y' 表示还可达,'n' 表示已被 GC
        // 正常情况 unrecycled 列表里应该都是 reachable 的
        // 如果出现 reachable='n',说明在上次检查和这次 dump 之间被 GC 了(正常的时间窗口)

        index++;
    }
    LOGW("===== End of Unrecycled Bitmaps =====");
}
kotlin 复制代码
#ifndef NATIVEDEMO_BITMAPPROXY_H
#define NATIVEDEMO_BITMAPPROXY_H

#include <android/log.h>
// jni.h 提供了 JNI 编程所需的所有类型和函数声明
// JNIEnv* : 每个线程独立的 JNI 函数表指针,所有 JNI 操作都要通过它
// jobject : Java 对象的引用(在 native 层是一个不透明的指针)
// jclass / jmethodID / jfieldID : 分别代表 Java 的类、方法、字段
#include <jni.h>

// ===== 以下三个是 C++ 标准库头文件 =====

// std::vector ------ C++ 的动态数组,类似 Java 的 ArrayList
// 相比 C 语言数组,vector 自动管理内存,可以动态增删元素
#include <vector>

// std::mutex ------ 互斥锁(mutual exclusion,互相排斥的锁)
// 同一时刻只有一个线程能持有这个锁,用来保护共享数据不被多个线程同时修改
// std::lock_guard ------ 锁的 RAII 包装器,构造时自动加锁,析构时自动解锁
// (RAII = Resource Acquisition Is Initialization,资源获取即初始化)
#include <mutex>

// std::atomic ------ 原子类型,对这个变量的读写操作是不可分割的
// 普通 bool 在多线程读写时可能读到中间状态,atomic<bool> 保证不会有这个问题
#include <atomic>

// std::thread ------ C++ 的线程类,类似 Java 的 Thread
// std::chrono ------ C++ 的时间库,用于 sleep 等时间相关操作
#include <thread>
#include <chrono>

// sys/time.h 提供 gettimeofday() 函数,用于获取当前时间戳(毫秒级精度)
#include <sys/time.h>

// ===== 日志宏 =====
#define LOG_TAG "NativeLog"
// __android_log_print 是 Android NDK 提供的日志函数
// ANDROID_LOG_DEBUG = 调试级别,ANDROID_LOG_WARN = 警告级别
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)

// ===== Hook 目标:需要 hook 的 .so 文件名 =====
// Android 10 (API 29) 之后,Bitmap 创建逻辑移到了 libhwui.so
#define BITMAP_CREATE_SYMBOL_SO_RUNTIME_AFTER_10 "libhwui.so"
// Android 10 之前,Bitmap 创建在 libandroid_runtime.so
#define BITMAP_CREATE_SYMBOL_SO_RUNTIME "libandroid_runtime.so"

// ===== Hook 目标:需要 hook 的函数符号名(C++ mangled name) =====
// C++ 支持函数重载,编译器会把函数名编码成包含参数类型的"mangled name"
// 这里的两串字符就是 createBitmap 函数经过编译器 name mangling 后的符号名

// Android 8.0 之前的 createBitmap 符号名
#define BITMAP_CREATE_SYMBOL_BEFORE_8 "_ZN11GraphicsJNI12createBitmapEP7_JNIEnvPN7android6BitmapEiP11_jbyteArrayP8_jobjecti"
// Android 8.0 及之后的 createBitmap 符号名
#define BITMAP_CREATE_SYMBOL_RUNTIME "_ZN7android6bitmap12createBitmapEP7_JNIEnvPNS_6BitmapEiP11_jbyteArrayP8_jobjecti"

// API Level 常量,用于版本判断
#define API_LEVEL_10_0 29
#define API_LEVEL_8_0 26

// ===== BitmapRecord 结构体 =====
// struct 是 C++ 中的数据结构,类似 Java 的 data class
// 每个 BitmapRecord 记录一次 Bitmap 创建事件的关键信息
// 我们用它来追踪哪些 Bitmap 还没被回收
struct BitmapRecord {
    // jweak: JNI 弱全局引用,指向 Java 层的 Bitmap 对象
    // 弱引用 vs 强引用:
    //   - 强引用 (NewGlobalRef): 会阻止 GC 回收对象 → 不合适,会制造新的泄漏
    //   - 弱引用 (NewWeakGlobalRef): 不阻止 GC,对象被 GC 后弱引用变成 NULL
    //   所以我们用弱引用,既能在 Bitmap 存活时访问它,又不会阻止它被正常回收
    jweak java_bitmap_ref;

    // mNativePtr 的值,Bitmap 内部字段,指向 native 层 Skia Bitmap 对象
    // 可以当作 Bitmap 的唯一标识
    jlong native_ptr;

    // Bitmap 的基本信息
    jint width;               // 图片宽度(像素)
    jint height;              // 图片高度(像素)
    jint stride;              // 每行像素占用的字节数(可能大于 width * 每像素字节数,因为有对齐)
    jint format;              // 像素格式,如 ARGB_8888=1, RGB_565=4 等(Android 内部格式常量)

    // 创建时间戳(毫秒),记录这个 Bitmap 是什么时候创建的
    long long create_time;
};

// ===== BitmapProxy 类 =====
// class 是 C++ 中的类,类似 Java 的 class
// public/private 控制成员的访问权限:
//   public: 外部可以访问
//   private: 只有类内部可以访问
class BitmapProxy {
public:
    // ----- 生命周期管理 -----

    // 启动代理:hook native 函数 + 开启后台轮询线程
    // 参数 jvm: JavaVM 指针,后台线程需要用它来获取 JNIEnv
    void startProxy(JavaVM *jvm);

    // 停止代理:取消 hook + 停止后台线程 + 清理数据
    void stopProxy();

    // ----- 由代理函数调用的接口 -----

    // 每当有新的 Bitmap 创建时,代理函数会调用这个方法记录一条数据
    // env: 当前线程的 JNI 环境
    // bitmap: 刚创建好的 Java Bitmap 对象
    // info: Bitmap 的尺寸格式信息
    void onBitmapCreated(JNIEnv *env, jobject bitmap,
                         jint width, jint height, jint stride, jint format,
                         jlong native_ptr);

    // ----- 对外查询接口 -----

    // 获取当前未被回收的 Bitmap 数量
    int getUnrecycledCount();

    // 将所有未被回收的 Bitmap 信息打印到 logcat
    void dumpUnrecycled(JNIEnv *env);

    // 获取未被回收 Bitmap 的总内存占用(字节)
    long long getUnrecycledSize();

private:
    // ----- 内部方法 -----

    // 初始化追踪系统:缓存 JNI 方法 ID、启动轮询线程
    void startTracking(JavaVM *jvm);

    // 停止追踪:通知轮询线程退出、等待线程结束、清理 JNI 全局引用
    void stopTracking();

    // 后台轮询线程的主函数,循环检查哪些 Bitmap 已被回收
    void pollLoop();

    // ===== 成员变量 =====
    // m_ 前缀是 C++ 中常见的成员变量命名约定(m = member)
    // 这样可以区分成员变量和局部变量

    // 轮询间隔(秒),多久检查一次
    // static constexpr: 编译期常量,不占用对象内存,类似 Java 的 static final
    static constexpr int CHECK_INTERVAL_SECONDS = 5;

    JavaVM *m_jvm = nullptr;
    // nullptr 是 C++11 的空指针,代替 C 语言的 NULL

    // ---- 缓存 JNI 相关的 ID,避免每次使用时重复查找 ----
    // jmethodID 是 JNI 中 Java 方法的"身份证号",拿到后可以反复使用
    jmethodID m_isRecycledMethod = nullptr;   // Bitmap.isRecycled() 方法 ID
    jclass m_bitmapClass = nullptr;            // android.graphics.Bitmap 类的全局引用

    // ---- 数据存储 ----
    // vector<BitmapRecord>: 一个动态数组,每个元素是一个 BitmapRecord
    // 存储所有"尚未确认已回收"的 Bitmap 记录
    std::vector<BitmapRecord> m_records;

    // mutex: 保护 m_records 的互斥锁
    // 因为代理函数可能在任意线程被调用(哪个线程创建 Bitmap 就在哪个线程触发 hook)
    // 而轮询线程也会读写 m_records,所以必须加锁保护
    std::mutex m_mutex;

    // ---- 后台线程 ----
    // atomic<bool>: 线程安全的布尔标志
    // .load() 读取当前值,.store() 设置新值
    // 轮询线程每轮循环开始时检查这个标志,为 false 就退出
    std::atomic<bool> m_polling{false};

    // 轮询线程对象
    std::thread m_pollThread;
};

// 全局指针,指向唯一的 BitmapProxy 实例
// extern 关键字:告诉编译器"这个变量在别处定义,这里只是声明"
// 在 BitmapProxyUtils.cpp 中定义,在 BitmapProxy.cpp 代理函数中使用
// 这样代理函数(是个 C 函数,不是成员函数)也能访问到 BitmapProxy 实例
extern BitmapProxy *g_bitmapProxy;

#endif //NATIVEDEMO_BITMAPPROXY_H
kotlin 复制代码
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 初始化 ShadowHook
        ShadowHook.init(
            ShadowHook.ConfigBuilder()
                .setMode(ShadowHook.Mode.SHARED)
                .build()
        )

        // 开启 Bitmap 创建监控(hook + 后台轮询线程)
        BitmapProxyUtils.startProxyBitmap()

        try {
            testCreateBitmap()
        } catch (e: Exception) {
            Log.e("BitmapTest", "testCreateBitmap error", e)
        }

        setupLeakDemo()
    }

    override fun onDestroy() {
        super.onDestroy()
        BitmapProxyUtils.stopProxyBitmap()
    }

    /**
     * 原始测试:通过各种方式创建 Bitmap,验证 hook 是否生效
     */
    private fun testCreateBitmap() {
        val tag = "BitmapTest"

        // 1. 给 ImageView 设置图片
        Log.d(tag, ">>> 1. ImageView.setImageResource")
        val imageView = findViewById<ImageView>(R.id.iv_logo_pixelated_0)
        imageView.setImageResource(R.mipmap.logo_pixelated_0)

        // 2. 通过 BitmapFactory 解码本地图片
        Log.d(tag, ">>> 2. BitmapFactory.decodeResource")
        val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.logo_pixelated_1)
        Log.d(tag, "decodeResource: ${bitmap.width}x${bitmap.height}, ${bitmap.byteCount} bytes")
        val imageView2 = findViewById<ImageView>(R.id.iv_logo_pixelated_1)
        imageView2.setImageBitmap(bitmap)

        // 3. 根据其他图片创建新图片 + setPixels
        Log.d(tag, ">>> 3. Bitmap.createBitmap + setPixels")
        val bitmapWidth = bitmap.width
        val bitmapHeight = bitmap.height
        val numPixels = bitmapWidth * bitmapHeight
        val pixels = IntArray(numPixels)
        bitmap.getPixels(pixels, 0, bitmapWidth, 0, 0, bitmapWidth, bitmapHeight)
        val copyBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
        copyBitmap.setPixels(pixels, 0, bitmapWidth, 0, 0, bitmapWidth, bitmapHeight)
        Log.d(tag, "createBitmap+setPixels: ${copyBitmap.width}x${copyBitmap.height}, ${copyBitmap.byteCount} bytes")

        // 4. 通过资源 id 获取 drawable
        // 注意:在 API 35 上 Resources.getDrawable() 走的是 ImageDecoder 管线
        // 不经过 android::bitmap::createBitmap,所以不会触发 hook
        Log.d(tag, ">>> 4. resources.getDrawable")
        val drawable: Drawable = resources.getDrawable(R.mipmap.logo_pixelated_2, theme)
        val imageView1 = findViewById<ImageView>(R.id.iv_logo_pixelated_2)
        imageView1.setImageDrawable(drawable)
        if (drawable is BitmapDrawable) {
            val bmp = drawable.bitmap
            Log.d(tag, "getDrawable(BitmapDrawable): ${bmp.width}x${bmp.height}, ${bmp.byteCount} bytes")
        }

        // 5. 创建大图
        Log.d(tag, ">>> 5. Bitmap.createBitmap 大图")
        val largeBmp = Bitmap.createBitmap(2000, 1500, Bitmap.Config.ARGB_8888)
        Log.d(tag, "createBitmap(2000x1500): ${largeBmp.width}x${largeBmp.height}, ${largeBmp.byteCount} bytes")

        Log.d(tag, ">>> testCreateBitmap 完成")
    }

    // ===== 泄漏检测 Demo =====

    // 故意持有 Bitmap 不释放,模拟内存泄漏
    // object 让这个列表变成"静态"的,和 Activity 的生命周期无关
    private val leakedBitmaps = mutableListOf<Bitmap>()

    // 故意持有但是调了 recycle() 的 Bitmap(正确用法,不会被标记为泄漏)
    private val recycledBitmaps = mutableListOf<Bitmap>()

    private fun setupLeakDemo() {
        val tag = "LeakDemo"

        val statusText = findViewById<TextView>(R.id.tv_status)
        val gcBtn = findViewById<Button>(R.id.btn_gc)
        val checkBtn = findViewById<Button>(R.id.btn_check_leak)
        val leakBtn = findViewById<Button>(R.id.btn_create_leak)
        val recycleBtn = findViewById<Button>(R.id.btn_create_recycled)

        // 按钮 1:创建泄漏 ------ 创建 3 个 Bitmap 存到静态列表,不调 recycle
        leakBtn.setOnClickListener {
            for (i in 1..3) {
                val bmp = Bitmap.createBitmap(500, 500, Bitmap.Config.ARGB_8888)
                // 存到静态列表中 → 这个引用永远不会被释放 → 内存泄漏
                leakedBitmaps.add(bmp)
                Log.w(tag, "Leaked bitmap #$i: ${bmp.width}x${bmp.height}")
            }
            updateStatus(tag, statusText)
        }

        // 按钮 2:正确用法 ------ 创建 3 个 Bitmap 后主动 recycle
        recycleBtn.setOnClickListener {
            for (i in 1..3) {
                val bmp = Bitmap.createBitmap(300, 300, Bitmap.Config.ARGB_8888)
                bmp.recycle()  // 主动释放 native 内存
                recycledBitmaps.add(bmp) // 虽然还持有引用,但已 recycle
                Log.i(tag, "Recycled bitmap #$i: ${bmp.width}x${bmp.height}")
            }
            updateStatus(tag, statusText)
        }

        // 按钮 3:主动触发 GC
        // 这会让那些"没人引用但没调 recycle"的 Bitmap 被 GC 回收
        gcBtn.setOnClickListener {
            Log.i(tag, "Requesting GC...")
            Runtime.getRuntime().gc()
            System.runFinalization()  // 触发 finalize() 方法
            Runtime.getRuntime().gc() // 再跑一次 GC,确保 finalize 创建的对象也被回收
        }

        // 按钮 4:查询未回收的 Bitmap
        checkBtn.setOnClickListener {
            updateStatus(tag, statusText)
            BitmapProxyUtils.dumpUnrecycledBitmaps()
        }

        updateStatus(tag, statusText)
    }

    private fun updateStatus(tag: String, statusText: TextView) {
        val count = BitmapProxyUtils.getUnrecycledBitmapCount()
        val size = BitmapProxyUtils.getUnrecycledBitmapSize()
        val sizeKB = size / 1024f
        val sizeMB = sizeKB / 1024f

        val msg = buildString {
            append("Unrecycled: $count bitmaps")
            append("\nTotal: ")
            if (sizeMB >= 1) {
                append("%.2f MB".format(sizeMB))
            } else {
                append("%.2f KB".format(sizeKB))
            }
            append("\nLeaked list size: ${leakedBitmaps.size}")
            append("\nRecycled list size: ${recycledBitmaps.size}")
        }

        statusText.text = msg
        Log.d(tag, msg.replace("\n", ", "))
    }
}

日志打印:

kotlin 复制代码
**026-07-23 17:14:45.323  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=420 h=126 stride=420 format=8 alloc=52920 bytes (51.68 KB)
2026-07-23 17:14:45.323  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=4
2026-07-23 17:14:45.330  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=500 h=500 stride=2000 format=1 alloc=1000000 bytes (976.56 KB)
2026-07-23 17:14:45.330  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=5
2026-07-23 17:14:45.331  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=500 h=500 stride=2000 format=1 alloc=1000000 bytes (976.56 KB)
2026-07-23 17:14:45.331  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=6
2026-07-23 17:14:45.331  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=500 h=500 stride=2000 format=1 alloc=1000000 bytes (976.56 KB)
2026-07-23 17:14:45.331  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=7
2026-07-23 17:14:45.689  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=300 h=300 stride=1200 format=1 alloc=360000 bytes (351.56 KB)
2026-07-23 17:14:45.689  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=8
2026-07-23 17:14:45.689  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=300 h=300 stride=1200 format=1 alloc=360000 bytes (351.56 KB)
2026-07-23 17:14:45.690  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=9
2026-07-23 17:14:45.690  2781-2781  NativeLog               com.example.bitmapstudy              D  create_bitmap_proxy: w=300 h=300 stride=1200 format=1 alloc=360000 bytes (351.56 KB)
2026-07-23 17:14:45.690  2781-2781  NativeLog               com.example.bitmapstudy              D  onBitmapCreated: recorded, total=10
2026-07-23 17:14:49.231  2781-2804  NativeLog               com.example.bitmapstudy              W  pollLoop: 7 unrecycled bitmaps, total size=3350840 bytes (3272.30 KB)
2026-07-23 17:14:54.238  2781-2804  NativeLog               com.example.bitmapstudy              W  pollLoop: 7 unrecycled bitmaps, total size=3350840 bytes (3272.30 KB)**

唯一触碰到 GOT 的地方

// BitmapProxy.cpp:149

stub = shadowhook_hook_sym_name(so, symbol,

(void *) create_bitmap_proxy, nullptr);

这一行代码做的事,翻译成 PLT/GOT 的语言就是:

  1. 在 so (libhwui.so) 的 .dynsym 里找到 symbol 对应的函数
  2. 遍历所有已加载 .so 的 GOT 表
  3. 找到 GOT 中哪些条目指向这个函数
  4. 把这些 GOT 条目的值从 原始地址 → 改成 create_bitmap_proxy 的地址
  5. 把原始地址保存到 stub 里
对比维度 GOT (全局偏移表) PLT (过程链接表)
本质 数据表(存储内存地址) 代码段(存储汇编跳转指令)
比喻 公司的"员工通讯录" 公司的"前台接待员"
内容 外部函数的真实内存地址 跳转到 GOT 表,或跳转到系统解析器的指令
可写性 可读写(运行时地址会被动态填入) 只读(代码段,运行时不允许修改)
作用 记录"目标函数到底在哪" 负责"把调用请求安全地转交出去"
  • PLT = 一段固定的跳板指令,只读,从生到死不变
  • GOT = 一张可改的地址表,动态链接器启动时往里填地址,Hook 运行时往里换地址
相关推荐
浮江雾4 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
admin and root4 小时前
「移动安全」安卓APP 反编译&frida脱壳技巧分享
android·开发语言·python·web安全·微信小程序·移动安全·攻防演练
ihuyigui5 小时前
海外签收通知短信接口
android·java·开发语言·前端·数据库·后端
大尚来也5 小时前
老项目 PHP 5.6 升级 PHP 8 完整迁移步骤与兼容坑汇总
android·adb
Android研究员6 小时前
Android进阶之事件分发机制深度剖析
android·前端·面试
峥嵘life7 小时前
Android WiFi连接过程 wpa_supplicant 日志分析
android·开发语言·php
Android小码家8 小时前
OWASP 移动应用安全之授权(MASTG-DEMO-0090)
android·安全·frida
Summer-Bright8 小时前
消费者 AI 变现竞争:从“一家独大“到“iOS vs Android“,谁在为 AI 买单?
android·人工智能·ios·ai·自然语言处理·agi
Xzaveir9 小时前
企业号码资产平台:逐号状态、事件日志与批次聚合
android·人工智能