手把手教你如何 Dump Native 线程栈和监听崩溃信号

手把手教你如何 Dump Native 线程栈和监听崩溃信号

我在前面的文章 Android 如何解读 Native 崩溃栈信息 中介绍了如何阅读 Native 的崩溃栈信息,今天我们来学习一下如何 Dump 一个 Native 线程中的栈和如何监听 Native 崩溃信号,源码在这里(如果觉得对你有帮助,希望能够得到你的 Star),结合源码来阅读本篇文章理解更容易。

Dump Native 线程栈

Linux 中方法的栈帧在运行时时保存在 .eh_frameSection 中,我们需要通过某种方法把当前方法栈帧的调用序列拿到。在程序加载到内存中时 .textSection 中存放了我们的代码指令,我们的进程会根据 ELF 文件的头信息,拿到入口函数在 .text 中的地址,然后从这个地址开始执行,通常这个入口函数就是 main() 函数,然后入口函数在执行过程中还会调用其他的函数,我们假如它在 Addr1 的地址执行了 fun1() 函数的调用,同理,fun1() 中又在 Addr2 中执行了 fun2() 函数的调用,假如我们在 fun2() 中的 Addr3 地址中去 Dump 当前栈信息,那我们从 .eh_frame 拿到的栈信息就是,Addr3 -> Addr2 -> Addr1,是的,我们拿到的信息就是一个地址信息,我们需要借助 .strtab 来解析这个地址是属于哪个方法,如果不熟悉 .strtab 符号表的同学可以看看我前面的文章。所以我们上面的地址解析后看到的大概就是这样 Addr3 (fun2 + 偏移量) -> Addr2 (fun1 + 偏移量) -> Addr1 (main + 偏移量),这个偏移量是当前的地址距离方法开始时的地址的偏移。

Android 中默认集成了 unwind 库,他就是用来 Dump 线程栈用到的库,我们也大致了解了 Dump 线程栈的原理,我们就来看看源码要如何实现。

首先需要通过 _Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *); 方法来获取栈帧中地址,也就是我上面举例的 Addr1Addr2Addr3。这个方法需要传两个参数,第一个就是处理栈帧信息的函数指针,第二个参数是我们第一个参数中的函数的自定义参数。_Unwind_Trace_Fn 函数指针的定义是:typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *, void *); ,这个函数指针的第一个参数 _Unwind_Context 中存放了方法栈帧中的很多重要的信息,也包括我们需要的指令的地址,第二个参数就是我们上面说的自定义参数。

C 复制代码
typedef struct {
    void **pcStart;
    void **pcEnd;
} DumpStackPcState;

static _Unwind_Reason_Code singleStackPcUnwind(_Unwind_Context *ctx, void *pcState) {
    auto* state = static_cast<DumpStackPcState *> (pcState);
    uintptr_t pc = _Unwind_GetIP(ctx);
    int pcOffset = 0;
#if defined(__arm__)
    pcOffset = -4;
#elif defined(__aarch64__)
    pcOffset = -4;
#endif
    if (pc) {
        if (state->pcStart == state->pcEnd) {
            return _URC_END_OF_STACK;
        } else {
            *state->pcStart++ = reinterpret_cast<void*>(pc + pcOffset);
        }
    }
    return _URC_NO_REASON;
}

static void dumpStackPc(DumpStackPcState* state) {
    _Unwind_Backtrace(singleStackPcUnwind, state);
}

我使用自定义参数 DumpStackPcState 来存放不同栈帧中的 pc 程序计数器的地址,存放的地址空间是 pcStartpcEnd,当超过 pcEnd 时就表示达到了设定的最大栈的数量。 以下是它的初始化:

C 复制代码
void *pcBuffer[result->maxStackSize];
DumpStackPcState s = { pcBuffer, pcBuffer + result->maxStackSize };

我们再来看看我们定义的栈帧处理回调函数 singleStackPcUnwind(),我们通过 _Unwind_GetIP() 方法去拿 _Unwind_Context 中的 pc 地址 (当然这个 ctx 还有这个栈帧的其他有用的信息,我们的需求只是要 pc 地址),我这里根据不同的 CPU 架构做了一个偏移值,然后把这个值写入到我们的自定义参数中,当超过我们的最大栈数量时返回 _URC_END_OF_STACK 表示停止栈的回溯,如果返回 _URC_NO_REASON 表示继续回溯。

到这里我们就把栈帧的相关的 pc 信息存放在了我们自定义的数据中了,接下来就是要解析 pc 中的信息。

以下就是解析 pc 信息的相关代码:

C 复制代码
// ...
void* inst_addr_in_mem = pcBuffer[i];
int offset = result->maxSingleStackSize * indexInStack;
char *target_output = result->stacks + offset;
if (dladdr(inst_addr_in_mem, &dl_info)) {
    const char *so_file_path = dl_info.dli_fname;
    const char *method_name = dl_info.dli_sname;
    long text_addr_in_mem = (long)dl_info.dli_fbase;
    long method_addr_in_mem = (long)dl_info.dli_saddr;
    long inst_addr_in_text = (long)inst_addr_in_mem - text_addr_in_mem;
    long inst_offset = (long)inst_addr_in_mem - method_addr_in_mem;
    if (!method_name) {
        sprintf(target_output, "#%02d pc %016lx  %s", indexInStack, inst_addr_in_text, so_file_path);
    } else {
        sprintf(target_output, "#%02d pc %016lx  %s (%s+%ld)", indexInStack, inst_addr_in_text, so_file_path, method_name, inst_offset);
    }
} else {
    sprintf(target_output, "#%02d", indexInStack);
}
// ...

我们需要通过 dladdr() 方法来解析上面通过 _Unwind_Backtrace 方法拿到的 pc 信息,解析的结果是存放在 Dl_info 中的,这里面的信息需要单独描述一下。

  • dli_fname 表示我们的程序文件的路径。
  • dli_sname 表示我们的 pc 所在的方法所对应的名字,因为方法的符号名字是在符号表中查询的,如果没有符号表,这个值就是空的。
  • dli_fbase 表示 .text 加载到内存中的开始的地址。
  • dli_saddr 表示当前调用的方法所对应的地址,和 dli_sname 一样,如果没有符号表,这个值也是空的。

我们理解了上面的参数表示的意思后,我们就能计算我们需要的信息了。补充一点 pc 表示当前执行的指令在内存中的地址。

指令在 .text Section 中的相对地址 = pc - dli_fbase
指令在对应方法的偏移量 = pc - dli_saddr

最后我们的 Dump 信息经过格式化处理后写入到 target_output 中。

以下是我测试的数据:

shell 复制代码
// ...
#04 pc 000000000001768c  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z13add5DumpCrashi+16)
#05 pc 00000000000176a4  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z14add10DumpCrashi+20)
#06 pc 00000000000176d4  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (Java_com_tans_stacktrace_MainActivity_testCrash+24)
#07 pc 000000000021a354  /apex/com.android.art/lib64/libart.so
#08 pc 000000000020a2b0  /apex/com.android.art/lib64/libart.so
#09 pc 0000000000209334  /apex/com.android.art/lib64/libart.so
// ...

看上去还是和 Android 默认的崩溃栈看上去差不多,哈哈😄。

监听 Native 崩溃信号

Android 中的崩溃信号主要有以下几种:

C 复制代码
static SigActionInfo sigActionInfos[] = {
        {.sig = SIGABRT},
        {.sig = SIGBUS},
        {.sig = SIGFPE},
        {.sig = SIGILL},
        {.sig = SIGSEGV},
        {.sig = SIGTRAP},
        {.sig = SIGSYS},
        {.sig = SIGSTKFLT}
};

不同的信号表示不同的意思,大家可以去找找别的文章,我这里就不介绍了。

默认我们拦截信号的处理函数也是工作在原来的函数栈上的,在类似于栈溢出的崩溃时,我们的拦截方法的可能会触发二次的崩溃,所以我们要通过以下的方法来让我们的信号处理函数工作在一个新的栈上。

C 复制代码
// ...
stack_t newStack;
newStack.ss_flags = 0;
int crashStackSize = 1024 * 128;
newStack.ss_size = crashStackSize;
newStack.ss_sp = malloc(crashStackSize);
sigaltstack(&newStack, nullptr);
// ...

我们新建的栈的大小为 128K。

然后我们要新创建一个新的信号处理:

C 复制代码
// ...
sigaction_s sigAction{};
sigfillset(&sigAction.sa_mask);
sigAction.sa_flags = SA_RESTART | SA_SIGINFO | SA_ONSTACK;
sigAction.sa_sigaction = sigHandler;
int sigActionInfoSize = sizeof(sigActionInfos) / sizeof(SigActionInfo);
int ret = 1;
for (int i = 0; i < sigActionInfoSize; i ++) {
    SigActionInfo info = sigActionInfos[i];
    if (sigaction(info.sig, &sigAction, info.oldAct) == 0) {
        ret = 0;
    }
}
// ...

首先构建一个 sigaction 结构体,通过 sigfillset() 方法清空它的 sa_masksa_sigaction 就是我们的信号处理函数指针,这里介绍一下 sa_flags 参数:

  • SA_RESTART 在处理信号时,系统处理会暂停,在添加该参数后,处理信号完成后就会继续执行,否者不会继续执行。
  • SA_SIGINFO 在信号处理函数中会添加信号相关的信息。
  • SA_ONSTACK 异常信号的处理函数工作在新的栈上,也就是我上面的设置。

然后后续的代码就是通过 sigaction() 方法来替换原来的信号处理,旧的信号处理存放在 info.oldAct 中,当我们自己的信号处理函数处理完成后,我们再把信号再传递给原来的处理函数(后面会看到这部分代码)。

我这里先直接展示完整的信号处理函数,我再慢慢解释:

C 复制代码
static void sigHandler(int sig, siginfo_t *sig_info, void *uc) {
    // auto * uctx = static_cast<ucontext *>(uc);
    auto tid = gettid();
    LOGE("Receive native crash: sig=%d, tid=%d", sig, tid);
    auto oldAct = findOldAct(sig);
    if (hasReceiveSig) {
        LOGE("Skip handle signal.");
        if (oldAct != nullptr) {
            oldAct->sa_sigaction(sig, sig_info, uc);
        }
        return;
    }
    hasReceiveSig = true;
    pid_t pid = fork();
    if (pid == 0) {
        // New process
        LOGD("Dump process start");
        DumpStackResult stackResult;
        stackResult.stacks = static_cast<char *>(malloc(
                stackResult.maxSingleStackSize * stackResult.maxStackSize));
        dumpStack(&stackResult, 1);
        printStackResult(&stackResult);
        int cacheFd = open(cacheFile, O_WRONLY);
        if (cacheFd > 0) {
            int writeCount = 0;
            writeCount += write(cacheFd, &stackResult.size, sizeof(stackResult.size));
            writeCount += write(cacheFd, &stackResult.maxSingleStackSize, sizeof(stackResult.maxSingleStackSize));
            writeCount += write(cacheFd, &stackResult.maxStackSize, sizeof(stackResult.maxStackSize));
            writeCount += write(cacheFd, stackResult.stacks, stackResult.maxSingleStackSize * stackResult.maxStackSize);
            close(cacheFd);
            LOGD("Write to cache, writeSize=%d", writeCount);
        } else {
            LOGE("Open cache file: %s, fail: %d", cacheFile, cacheFd);
        }
        LOGD("Crash handle thread work finished.");
        free(stackResult.stacks);
        _Exit(0);
    }
    if (pid > 0) {
        // Current process
        LOGD("Waiting dump process: %d", pid);
        int status;
        waitpid(pid, &status, __WALL);
        LOGD("Waiting crash handle thread.");
        timeval tv{};
        gettimeofday(&tv, nullptr);
        long timeInMillis = tv.tv_sec * 1000L + tv.tv_usec / 1000L;
        crashTid = tid;
        crashSig = sig;
        crashSigSub = sig_info->si_code;
        crashTime = timeInMillis;
        long data = 233;
        write(crashNotifyFd, &data, sizeof(long));
        if (crashNotifyFd > 0) {
            close(crashNotifyFd);
            crashNotifyFd = -1;
        }
        pthread_join(crashHandleThread, nullptr);
        LOGD("Dump process finished: %d", status);
        if (oldAct != nullptr) {
            oldAct->sa_sigaction(sig, sig_info, uc);
        }
    }
}

我的这个函数中用到了 fork() 来创建了一个子进程,所以我这里有必要对 fork 做一个简单的介绍,fork 是在 Linux 中创建进程的一种常见的方式,fork() 方法也是非常神奇,它会调用一次返回两次,你可能听着有点懵,父进程中 fork() 函数会返回子进程的 pid,而子进程中的 fork() 返回值为 0,在 fork() 函数调用后子进程也就创建成功了,子进程中他会复用父进程中原来的数据和状态,这部分数据也是父进程和它的每个子进程共享的,当子进程要尝试修改公用的数据时那么就会把这部分数据在写入到自己的进程中内存中,而父进程中的数据不会受影响,子进程中下次去那这个数据也不会从父进程中去拿了,而是拿自己的那部分数据,这这部分数据所占用的内存就被称为 private dirty (子进程中新申请的内存也是属于 private dirty),直接翻译过来就是私有脏数据。在 Android 中,我们的进程都是由 Zygoty fork 而来,Zygoty 中有非常多的共享数据都是由所有的进程共享的,在需要创建新的应用进程时,AMS 会通过 Socket 发送消息给 Zygoty 进程,然后 Zegoty 进程会通过 fork 创建子进程,然后在子进程中会执行 ActivityThreadmain() 函数,这就是我们应用进程主线程开始的地方。

我们再回到我们上面的代码,为什么我们这里要用 fork 呢?按照网络上的描述,在处理信号的过程中其中的方法栈信息可能被修改,通过 fork 后来用子线程来处理就可以避免这种问题。说实话我也不确定这个描述的准确性,我自己不 fork 貌似得到的数据也是对的。像 xCrashBugly 等等他们也都是 fork 一个子进程来 dump 栈信息,那我们也按照通用的方法来吧。。。。。

我们来看看子进程如何 dump 栈信息:

scss 复制代码
    // ...
    if (pid == 0) {
        // New process
        LOGD("Dump process start");
        DumpStackResult stackResult;
        stackResult.stacks = static_cast<char *>(malloc(
                stackResult.maxSingleStackSize * stackResult.maxStackSize));
        dumpStack(&stackResult, 1);
        printStackResult(&stackResult);
        int cacheFd = open(cacheFile, O_WRONLY);
        if (cacheFd > 0) {
            int writeCount = 0;
            writeCount += write(cacheFd, &stackResult.size, sizeof(stackResult.size));
            writeCount += write(cacheFd, &stackResult.maxSingleStackSize, sizeof(stackResult.maxSingleStackSize));
            writeCount += write(cacheFd, &stackResult.maxStackSize, sizeof(stackResult.maxStackSize));
            writeCount += write(cacheFd, stackResult.stacks, stackResult.maxSingleStackSize * stackResult.maxStackSize);
            close(cacheFd);
            LOGD("Write to cache, writeSize=%d", writeCount);
        } else {
            LOGE("Open cache file: %s, fail: %d", cacheFile, cacheFd);
        }
        LOGD("Crash handle thread work finished.");
        free(stackResult.stacks);
        _Exit(0);
    }
    // ...

其实这部分代码就朴实无华了,就用我们上一节描述的方法来 dump 栈信息,然后把这个数据再写入到缓存文件中。

我们再来看看主进程中:

ini 复制代码
    // ...
    if (pid > 0) {
        // Current process
        LOGD("Waiting dump process: %d", pid);
        int status;
        waitpid(pid, &status, __WALL);
        LOGD("Waiting crash handle thread.");
        timeval tv{};
        gettimeofday(&tv, nullptr);
        long timeInMillis = tv.tv_sec * 1000L + tv.tv_usec / 1000L;
        crashTid = tid;
        crashSig = sig;
        crashSigSub = sig_info->si_code;
        crashTime = timeInMillis;
        long data = 233;
        write(crashNotifyFd, &data, sizeof(long));
        if (crashNotifyFd > 0) {
            close(crashNotifyFd);
            crashNotifyFd = -1;
        }
        pthread_join(crashHandleThread, nullptr);
        LOGD("Dump process finished: %d", status);
        if (oldAct != nullptr) {
            oldAct->sa_sigaction(sig, sig_info, uc);
        }
    }
    // ...

父进程中等待子进程中完成 dump,然后通知 CrashHandleThread 去处理子进程 dump 好的数据,然后等待 CrashHandleThread 工作完成,然后调用原来旧信号处理函数的方法。这里是通过向 fd 中写入了一个 233 来通知 CrashHandleThread,在初始化时就会启动 CrashHandleThread,它在 read 上面的 fd,当有数据来时就会读取到一个 233,然后他就处理 dump 数据。

我们再来看看 CrashHandleThread

C 复制代码
static void* crashHandleRoutine(void* args) {
    auto *args_t = static_cast<CrashHandleThreadArgs *>(args);
    auto *jniEnv = args_t->env;
    LOGD("Crash handle thread started.");
    long data;
    if (crashNotifyFd > 0) {
        JavaVMAttachArgs jvmAttachArgs {
          .version = JNI_VERSION_1_6,
          .name = "CrashHandleThread",
          .group = nullptr
        };
        if (args_t->jvm->AttachCurrentThread(&jniEnv, &jvmAttachArgs) != JNI_OK) {
            LOGE("Attach jvm thread fail.");
            return nullptr;
        }
        while (true) {
            read(crashNotifyFd, &data, sizeof(data));
            LOGD("Crash handle read data: %ld", data);
            if (data == 233L) {
                LOGD("Crash handle thread receive crash, waiting crash handle thread: %ld", crashHandleThread);
                close(crashNotifyFd);
                crashNotifyFd = -1;
                struct stat cacheStat {};
                stat(cacheFile, &cacheStat);
                long long cacheSize = cacheStat.st_size;
                LOGD("Cache file size: %lld", cacheSize);
                int cacheFd = 0;
                if (cacheSize > 0) {
                    cacheFd = open(cacheFile, O_RDONLY);
                }
                if (cacheFd > 0) {
                    int crashStackSize, maxSingleStackSize, maxStackSize;
                    read(cacheFd, &crashStackSize, sizeof(int));
                    read(cacheFd, &maxSingleStackSize, sizeof(int));
                    read(cacheFd, &maxStackSize, sizeof(int));
                    LOGD("CrashStackSize=%d, MaxSingleStackSize=%d, MaxStackSize=%d", crashStackSize, maxSingleStackSize, maxStackSize);
                    if (crashStackSize > 0 && maxSingleStackSize > 0 && maxStackSize > 0) {
                        char *stacks = static_cast<char *>(malloc(crashStackSize * maxSingleStackSize));
                        read(cacheFd, stacks, crashStackSize * maxSingleStackSize);
                        LOGD("Read stacks: %s", stacks);
                        CrashData crashData {
                            .tid = crashTid,
                            .sig = crashSig,
                            .sigName = getSigName(crashSig),
                            .sigSub = crashSigSub,
                            .sigSubName = getSigSubName(crashSig, crashSigSub),
                            .time = crashTime,
                            .stackResult = DumpStackResult {
                                .size = crashStackSize,
                                .maxSingleStackSize = maxSingleStackSize,
                                .maxStackSize = maxStackSize,
                                .stacks = stacks
                            }
                        };
                        args_t->crashHandle(jniEnv, args_t->obj, &crashData);
                        free(stacks);
                    } else {
                        LOGE("Wrong size.");
                    }
                    close(cacheFd);
                } else {
                    LOGE("Read cache file fail: %d", cacheFd);
                }
                break;
            }
        }
        args_t->jvm->DetachCurrentThread();
    } else {
        LOGE("Crash handle fail: crash notify fd is invalid.");
    }
    free(args);
    return nullptr;
}

这里要注意因为后续涉及到调用 Java 的方法所以在调用前必须通过 AttachCurrentThread 方法添加到 JVM 中去,其中有用到的对象要通过 NewGlobalRef 方法添加一个强引用。否则会报错。

这里就是通过读取子进程中写入的信息,然后把读取到的值通过 crashHandle 函数指针回调给 Java 层。

这里还有一点特别坑,Java 中的 tidLinux 中的 tid 是不一样的,Java 中的 tid 就是一个 int 值在 ++,我们需要通过读 /proc/[tid]/comm 中去读线程的名字,然后根据线程的名字再去 Java 的线程中去找对应的线程。

下面是我的一个测试崩溃数据:

text 复制代码
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 10485 (TestCrashThread), pid 10030 (com.tans.stacktrace)


     #04 pc 000000000001768c  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z13add5DumpCrashi+16)
     #05 pc 00000000000176a4  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (_Z14add10DumpCrashi+20)
     #06 pc 00000000000176d4  /data/app/~~OT28_jT5HM9S3y-FvnD3BQ==/com.tans.stacktrace-Xjr_DLGp5lFseeHN9GRGUA==/base.apk!/lib/arm64-v8a/libstacktrace.so (Java_com_tans_stacktrace_MainActivity_testCrash+24)
     #07 pc 000000000021a354  /apex/com.android.art/lib64/libart.so
     #08 pc 000000000020a2b0  /apex/com.android.art/lib64/libart.so
     #09 pc 0000000000209334  /apex/com.android.art/lib64/libart.so
     #10 pc 0000000000209334  /apex/com.android.art/lib64/libart.so
     #11 pc 000000000020b074  /apex/com.android.art/lib64/libart.so
     #12 pc 000000000021096c  /apex/com.android.art/lib64/libart.so
     #13 pc 000000000027af68  /apex/com.android.art/lib64/libart.so (_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc+184)
     #14 pc 0000000000624cac  /apex/com.android.art/lib64/libart.so (_ZN3art35InvokeVirtualOrInterfaceWithJValuesIPNS_9ArtMethodEEENS_6JValueERKNS_33ScopedObjectAccessAlreadyRunnableEP8_jobjectT_PK6jvalue+460)
     #15 pc 000000000066de60  /apex/com.android.art/lib64/libart.so (_ZN3art6Thread14CreateCallbackEPv+1296)
     #16 pc 00000000000eb720  /apex/com.android.runtime/lib64/bionic/libc.so
     at com.tans.stacktrace.MainActivity.testCrash(Native Method)
     at com.tans.stacktrace.MainActivity.onCreate$lambda$3$lambda$2(MainActivity.kt:49)
     at com.tans.stacktrace.MainActivity.$r8$lambda$xuM_bd7L1pB3XG2xgdny9HZIqC0(Unknown Source:0)
     at com.tans.stacktrace.MainActivity$$ExternalSyntheticLambda0.run(Unknown Source:2)
     at java.lang.Thread.run(Thread.java:1012)

看上去也还行哈,而且我还把 Java 栈也打印了。

最后

我写的这些代码只是供学习使用,如果你想要用于线上环境需谨慎,很多机型都没有测试过,建议使用其他的稳定的开源的库,例如 xCrash,在你读了我这个简单版的捕获 Native 崩溃的代码后,再去阅读别的捕获 Native 异常库的代码会更容易理解,大家的原理都是差不多的。

相关推荐
dal118网工任子仪5 小时前
91,【7】 攻防世界 web fileclude
android·前端
taopi20245 小时前
android java 用系统弹窗的方式实现模拟点击动画特效
android
fanged5 小时前
Android学习19 -- 手搓App
android
dal118网工任子仪6 小时前
99,[7] buuctf web [羊城杯2020]easyphp
android·前端·android studio
村口老王11 小时前
鸿蒙开发——应用程序包及模块化设计
android·前端·harmonyos
6v6博客11 小时前
如何在 Typecho 中实现 Joe 编辑器标签自动填充
android·编辑器
轻口味13 小时前
Vue.js `v-memo` 性能优化技巧
前端·vue.js·性能优化
程序员牛肉15 小时前
为什么网络上一些表情包在反复传播之后会变绿?“电子包浆”到底是怎么形成的?
android
志尊宝15 小时前
Android 深入探究 JSONObject 与 JSONArray:Android 中的数据解析与数组操作全解析
android
莫名有雪1 天前
BUUCTF_[网鼎杯 2020 朱雀组]phpweb(反序列化绕过命令)
android