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 运行时往里换地址
相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui