查看原文
其他

Android 15 线程挂起超时崩溃与修复

巴黎没有摩天轮Li 鸿洋
2024-08-24

本文作者


作者:巴黎没有摩天轮Li

链接:

https://juejin.cn/post/7390341683601014824

本文由作者授权发布。


背景

由于Android系统针对挂起线程超时的场景进行了进程中断处理,从系统稳定性的角度,Google 这么做也是没有问题的,但是从应用侧我们是不希望用户使用时崩溃的。

Android 线程挂起超时崩溃与修复

https://juejin.cn/post/7364409181053206554


Android 线程挂起超时崩溃与修复 - 续集

https://juejin.cn/post/7379060488351399946


Android Native 线程挂起流程

https://juejin.cn/post/7372572344248516635


我们修复以后线上的收益还不错(基于已存在很多线程挂起超时崩溃问题),但是最近Android 15线上已经有部分测试设备,导致近7天40台设备,大概线程挂起超时导致 500-600 次崩溃,所以提前把方案定制好,自测已通过。

由于前因后果已经在前三篇文章中详细梳理了本篇直接上方案。

1AOSP 源码改动梳理


估计 Google 对线程挂起流程也觉得有可优化的地方吧,此次挂起流程调整的相对来说比较大了,有兴趣可以在 thread_list.cc 中详细看到变更点。

https://cs.android.com/android/_/android/platform/art/+/d00d24530a29b684bec9a895c1da491a6390395f:runtime/thread_list.cc;dlc=7b7adc7f774f1237950ee9a5b9e3d2afbd8300d9


SuspendThreadByPeer & SuspendThreadByThreadId

我们之前的hook手段都是围绕这两个函数做文章的,这回在 Android 15中,挂起逻辑被整合到SuspendThread 函数中了,我们看下具体的挂起实现。

https://cs.android.com/android/platform/superproject/main/+/main:art/runtime/thread_list.cc;drc=934ce638055e09afd43ec344a2bdf8060fb91978;bpv=1;bpt=1;l=1045?q=thread_list.cc&gsn=SuspendThread&gs=KYTHE%3A%2F%2Fkythe%3A%2F%2Fandroid.googlesource.com%2Fplatform%2Fsuperproject%2Fmain%2F%2Fmain%3Flang%3Dc%252B%252B%3Fpath%3Dart%2Fruntime%2Fthread_list.cc%23Gx1QFEjlvX8eapqRF26zlSvkbEKo9c2evLMrn3Hb3s0


SuspendThread

bool ThreadList::SuspendThread(Thread* self,
                               Thread* thread,
                               SuspendReason reason,
                               ThreadState self_state,
                               const char* func_name,
                               int attempt_of_4) {
  bool is_suspended = false;
  VLOG(threads) << func_name << "starting";
  pid_t tid = thread->GetTid();
  uint8_t suspended_count;
  uint8_t checkpoint_count;
  WrappedSuspend1Barrier wrapped_barrier{}; // 挂起栅栏
  static_assert(sizeof wrapped_barrier.barrier_ == sizeof(uint32_t));
  ThreadExitFlag tef;
  bool exited = false;
  thread->NotifyOnThreadExit(&tef);
  int iter_count = 1;
  do {
    {
      Locks::mutator_lock_->AssertSharedHeld(self);
      Locks::thread_list_lock_->AssertHeld(self);
      // Note: this will transition to runnable and potentially suspend.
      DCHECK(Contains(thread));
      // This implementation fails if thread == self. Let the clients handle that case
      // appropriately.
      CHECK_NE(thread, self) << func_name << "(self)";
      VLOG(threads) << func_name << " suspending: " << *thread;
      {
        MutexLock suspend_count_mu(self, *Locks::thread_suspend_count_lock_);
        if (LIKELY(self->GetSuspendCount() == 0)) {
          suspended_count = thread->suspended_count_;
          checkpoint_count = thread->checkpoint_count_;
          // 老样子,设置一个挂起标记位
          thread->IncrementSuspendCount(self, nullptr, &wrapped_barrier, reason);
          if (thread->IsSuspended()) {
            // 如果挂起就移除挂起栅栏
            // See the discussion in mutator_gc_coord.md and SuspendAllInternal for the race here.
            thread->RemoveFirstSuspend1Barrier(&wrapped_barrier);
            if (!thread->HasActiveSuspendBarrier()) {
              thread->AtomicClearFlag(ThreadFlag::kActiveSuspendBarrier);
            }
            // 直接返回挂起成功
            is_suspended = true;
          }
          DCHECK_GT(thread->GetSuspendCount(), 0);
          break;
        }
      }
    }
    // All locks are released, and we should quickly exit the suspend-unfriendly state. Retry.
    if (iter_count >= kMaxSuspendRetries) {
      LOG(FATAL) << "Too many suspend retries";
    }
    Locks::thread_list_lock_->ExclusiveUnlock(self);
    {
      ScopedThreadSuspension sts(self, ThreadState::kSuspended);
      usleep(kThreadSuspendSleepUs);
      ++iter_count;
    }
    Locks::thread_list_lock_->ExclusiveLock(self);
    exited = tef.HasExited();
  } while (!exited);
  thread->UnregisterThreadExitFlag(&tef);
  Locks::thread_list_lock_->ExclusiveUnlock(self);
  self->TransitionFromRunnableToSuspended(self_state);
  if (exited) {
    return false;
  }
  // Now wait for target to decrement suspend barrier.
  std::optional<std::string> failure_info;
  if (!is_suspended) {
    // 如果还没有迅速挂起,则走一个超时计时逻辑,并将wrapped_barrier结构体传入
    failure_info = WaitForSuspendBarrier(&wrapped_barrier.barrier_, tid, attempt_of_4);
    if (!failure_info.has_value()) {
      // 如果返回值没有值 则说明已经立即挂起。
      is_suspended = true;
    }
  }
  while (!is_suspended) {
    // 未挂起,则陷入死循环
    if (attempt_of_4 > 0 && attempt_of_4 < 4) {
      MutexLock suspend_count_mu(self, *Locks::thread_suspend_count_lock_);
      if (wrapped_barrier.barrier_.load() == 0) {
      // 获取一下栅栏中的一个 int 类型的原子变量值是否为 0,为 0则代表挂起成功没有超时。
        // Succeeded in the meantime.
        is_suspended = true;
        continue;
      }
      // 移除栅栏
      thread->RemoveSuspend1Barrier(&wrapped_barrier);
      if (!thread->HasActiveSuspendBarrier()) {
        thread->AtomicClearFlag(ThreadFlag::kActiveSuspendBarrier);
      }
      thread->DecrementSuspendCount(self,
                                    /*for_user_code=*/(reason == SuspendReason::kForUserCode));
      Thread::resume_cond_->Broadcast(self);
      return false;
    }
    std::string name;
    thread->GetThreadName(name);
    WrappedSuspend1Barrier* first_barrier;
    {
      MutexLock suspend_count_mu(self, *Locks::thread_suspend_count_lock_);
      first_barrier = thread->tlsPtr_.active_suspend1_barriers;
    }
    // 组合一个错误信息 重点。
    std::string message = StringPrintf(
        "%s timed out: %d (%s), state&flags: 0x%x, priority: %d,"
        " barriers: %p, ours: %p, barrier value: %d, nsusps: %d, ncheckpts: %d, thread_info: %s",
        func_name,
        thread->GetTid(),
        name.c_str(),
        thread->GetStateAndFlags(std::memory_order_relaxed).GetValue(),
        thread->GetNativePriority(),
        first_barrier,
        &wrapped_barrier,
        wrapped_barrier.barrier_.load(),
        thread->suspended_count_ - suspended_count,
        thread->checkpoint_count_ - checkpoint_count,
        failure_info.value().c_str());
    if (wrapped_barrier.barrier_.load() != 0) {
      // 触发崩溃
      thread->AbortInThis(message);
      UNREACHABLE();
    }
    is_suspended = true;
  }
  // ignore ...
  return true;
}
上述的函数核心是负责触发线程挂起请求的逻辑,关于线程是否挂起超时,整体的逻辑封装到WaitForSuspendBarrier函数中,它对结构体中的AtomicInteger类型的barrier值进行调整。
这里我们可以快速理解一下,
线程挂起超时
wrapped_barrier.barrier_.load() != 0
线程挂起非超时
wrapped_barrier.barrier_.load() == 0

WaitForSuspendBarrier

std::optional<std::string> ThreadList::WaitForSuspendBarrier(AtomicInteger* barrier,
                                                             pid_t t,
                                                             int attempt_of_4) {
#if ART_USE_FUTEXES
  const uint64_t start_time = NanoTime();
#endif
  uint64_t timeout_ns =
      attempt_of_4 == 0 ? thread_suspend_timeout_ns_ : thread_suspend_timeout_ns_ / 4;
  if (attempt_of_4 != 1 && getpriority(PRIO_PROCESS, 0 /* this thread */) > 0) {
    // 我们是一个低优先级线程,因此有更长的 ANR 超时时间。将挂起超时加倍。
    // 为了避免在常见情况下调用 getpriority 系统调用,我们在四次等待的第一次未加倍,
    // 但在第三次将其增加三倍以补偿。
    if (attempt_of_4 == 3) {
      timeout_ns *= 3;
    } else {
      timeout_ns *= 2;
    }
  }
  bool collect_state = (t != 0 && (attempt_of_4 == 0 || attempt_of_4 == 4));
  int32_t cur_val = barrier->load(std::memory_order_acquire);
  if (cur_val <= 0) {
    DCHECK_EQ(cur_val, 0);
    return std::nullopt;
  }
  unsigned i = 0;
  if (WaitOnceForSuspendBarrier(barrier, cur_val, timeout_ns)) {
    i = 1;
  }
  cur_val = barrier->load(std::memory_order_acquire);
  if (cur_val <= 0) {
    DCHECK_EQ(cur_val, 0);
    return std::nullopt;
  }

  // 长时间等待;在超时情况下收集信息。
  std::string sampled_state = collect_state ? GetOsThreadStatQuick(t) : "";
  while (i < kSuspendBarrierIters) {
    if (WaitOnceForSuspendBarrier(barrier, cur_val, timeout_ns)) {
      ++i;
#if ART_USE_FUTEXES
      if (!kShortSuspendTimeouts) {
        CHECK_GE(NanoTime() - start_time, i * timeout_ns / kSuspendBarrierIters - 1'000'000);
      }
#endif
    }
    cur_val = barrier->load(std::memory_order_acquire);
    if (cur_val <= 0) {
      DCHECK_EQ(cur_val, 0);
      return std::nullopt;
    }
  }
  return collect_state ? "Target states: [" + sampled_state + ", " + GetOsThreadStatQuick(t) + "]" +
                             std::to_string(cur_val) + "@" + std::to_string((uintptr_t)barrier) +
                             " Final wait time: " + PrettyDuration(NanoTime() - start_time) :
                         "";
}

static constexpr bool kShortSuspendTimeouts = false;
static constexpr unsigned kSuspendBarrierIters = kShortSuspendTimeouts ? 5 : 20;

在正常运行中,kShortSuspendTimeouts 为 false,kSuspendBarrierIters 的值为 20。在调试模式中,kShortSuspendTimeouts 设置为 true,此时 kSuspendBarrierIters 的值为 5。

WaitOnceForSuspendBarrier

// Returns true if it timed out.
static bool WaitOnceForSuspendBarrier(AtomicInteger* barrier,
                                    int32_t cur_val,
                                    uint64_t timeout_ns) 
{
// 定义一个 timespec 结构体变量,用于存储超时信息。
timespec wait_timeout;

// 判断是否启用了短超时(kShortSuspendTimeouts 为 true)
if (kShortSuspendTimeouts) {
  // 将 timeout_ns 设置为 MsToNs(kSuspendBarrierIters)
  timeout_ns = MsToNs(kSuspendBarrierIters);
  // 检查 timeout_ns / kSuspendBarrierIters 的毫秒值是否大于等于 1
  CHECK_GE(NsToMs(timeout_ns / kSuspendBarrierIters), 1ul);
else {
  // 否则,检查 timeout_ns / kSuspendBarrierIters 的毫秒值是否大于等于 10
  DCHECK_GE(NsToMs(timeout_ns / kSuspendBarrierIters), 10ul);
}

// 使用 InitTimeSpec 函数初始化 wait_timeout 结构体
// 设置时钟类型为 CLOCK_MONOTONIC,超时值为 timeout_ns / kSuspendBarrierIters 的毫秒值
InitTimeSpec(false, CLOCK_MONOTONIC, NsToMs(timeout_ns / kSuspendBarrierIters), 0, &wait_timeout);

// 调用 futex 系统调用等待屏障的地址值为 cur_val
// FUTEX_WAIT_PRIVATE 表示在当前进程内等待
if (futex(barrier->Address(), FUTEX_WAIT_PRIVATE, cur_val, &wait_timeout, nullptr0) != 0) {
  // 检查 errno
  if (errno == ETIMEDOUT) {
    // 如果 errno 为 ETIMEDOUT,表示超时,返回 true
    return true;
  } else if (errno != EAGAIN && errno != EINTR) {
    // 如果 errno 不是 EAGAIN 或 EINTR,记录错误日志并终止程序
    PLOG(FATAL) << "futex wait for suspend barrier failed";
  }
}

return false;
}
综上代码提取一下超时时间的计算:在非 Debug 模式下,kSuspendBarrierIters = 20, 挂起超时的判断利用 futex 挂起函数的超时特性来判断。
也就是 20次迭代 ✖️ futex挂起超时时间 = 最大挂起等待时间。
所以当线程走到检查点,挂起线程以后,会将 futex 中的 cur_val 期望值,也就是barrier->Address()的值设置为 0。

ok,到了这里我们就知道新版本的挂起超时检测机制是什么了。

2Hook 方案选择


Android 15 的思考方案

https://juejin.cn/post/7379060488351399946#heading-6


在 Android 15修复的问题上我提前思考了一下,但是还没有实际测试,最近线上 Android 15 不少崩溃,就提前做了一下。
wrapped_barrier的结构体内存地址传递给StringPrintf函数,还好有这个函数,不然真没办法处理了,嘘,不会被 Google 的开发看到吧 ^_^。
同样使用inline-hook去代理 StringPrintf,由于 StringPrintf函数位于libbase.so中,所以可以直接 hook。
#define SYMBOL_STRING_PRINTF "_ZN7android4base12StringPrintfEPKcz"

const char *getStringPrintfFunctionName() {
    return SYMBOL_STRING_PRINTF;
}

namespace hookThreadSuspendAbortV15 {
    jobject callbackObj = nullptr;
    void *originalStringPrintf = nullptr;

    typedef void *(*StringPrintf_t)(const char *format, ...);

    bool checkFormat(const char *format);

    void *proxyStringPrintfFunc(const char *format, ...) {
        // todo
        return originCallback;
    }

    void fixNativeThreadSuspend(JNIEnv *env, jobject callback) {
        BaseInlineHook baseInlineHook = BaseInlineHook(env);
        baseInlineHook.callbackObj = env->NewGlobalRef(callback);
        callbackObj = baseInlineHook.callbackObj;
        baseInlineHook.setupHook(TARGET_LIB_BASE,
                                 getStringPrintfFunctionName(),
                                 (void *) proxyStringPrintfFunc,
                                 (void **) &originalStringPrintf);
    }

得意洋洋的打开 Android 15 虚拟机,模拟了线程挂起超时的崩溃,但是回调函数没有被调用,百思不得其解,于是也去 ShadowHook 上创建了一个讨论

https://github.com/bytedance/android-inline-hook/discussions/70


理论上,我理解使用 inline-hook 去 hook libart.so 中的函数A, 函数A 间接调用了 libbase.so 中的函数B , 我直接 hook 这个 B 函数,自定义一个 Proxy B,当A调用B时候,这个 Proxy B 理论上也会被调用。
但是的确没有收到调用,而且我使用dlsym主动触发StringPrintf函数,是能够收到 Proxy 调用的,说明写的没有问题。唯一只能说明我这个虚拟机内核代码没有调用StringPrintf函数。卡在这里半天,所以我在想如何证明我这个虚拟机内部代码确实在挂起线程的时候执行了这个StringPrintf函数呢。于是还是想从 libart.so入手,那么 objdump 能不能帮我们查出这个函数在哪些地方被调用了呢。所以我执行了一下 objdump -d libart.so > libart_dump.txt的到反汇编代码,然后我看到如下。
那不爽歪歪了,这个函数并不是直接调用的而是使用 PLT调用的,然后 PLT 使用全局偏移表(GOT)找到共享库中函数的实际地址。所以直接使用 PLT hook 大概率可以成功。

所以又集成了 BHook,果然成功了,也许使用修改 GOT 表中的目标地址更加直接一点,不过 inline-hook 为什么不可以还需要再研究一下。

https://github.com/bytedance/bhook


2024.07.16 更新
如图所示,Inline-Hook其实也是可以的,只是同名 so 导致的问题。

方案实现

ok,扫平了 hook 方案,我们开始写 hook 成功后的逻辑,直接上代码。
namespace hookThreadSuspendAbortV15 {
    jobject callbackObj = nullptr;
    void *stubFunction = nullptr;

    bool checkFormat(const char *format);

    std::string proxyStringPrintfFunc(const char *format, ...) {
        BYTEHOOK_STACK_SCOPE();
        if (checkFormat(format)) {
            va_list args;
            va_start(args, format);
            const char *func_name = va_arg(args, const char*);  // func_name
            va_arg(args, int);          // tid
            va_arg(args, const char*);  // name.c_str()
            va_arg(args, int);          // state_and_flags
            va_arg(args, int);          // native_priority
            va_arg(args, void*);        // first_barrier
            using namespace kbArt;
            WrappedSuspend1Barrier *wrappedBarrier = va_arg(args, WrappedSuspend1Barrier*);
            if (wrappedBarrier != nullptr) {
                if (wrappedBarrier->barrier_.load(std::memory_order_acquire) == 0) {
                    return base::StringPrintf("thread has been suspend : %s", func_name);
                }
                struct timespec startTime{};
                clock_gettime(CLOCK_MONOTONIC, &startTime);

                struct timespec ts{};
                ts.tv_sec = 0;
                ts.tv_nsec = 10000000;

                while (true) {
                    if (wrappedBarrier->barrier_.load(std::memory_order_acquire) == 0) {
                        struct timespec endTime{};
                        clock_gettime(CLOCK_MONOTONIC, &endTime);
                        double waitDuration = (endTime.tv_sec - startTime.tv_sec) + (endTime.tv_nsec - startTime.tv_nsec) / 1e9;
                        NotifyHandleThreadSuspendTimeout::triggerSuspendTimeout(callbackObj, std::round(waitDuration * 1000) / 1000);
                        return base::StringPrintf("thread has been suspend : %s, cost time %f", func_name, waitDuration);
                    }
                    nanosleep(&ts, nullptr);
                }
            }
            va_end(args);
        }

        va_list ap;
        va_start(ap, format);
        std::string result;
        base::StringAppendV(&result, format, ap);
        va_end(ap);
        return result;
    }

    void fixNativeThreadSuspend(JNIEnv *env, jobject callback) {
        callbackObj = env->NewGlobalRef(callback);

        if(stubFunction != nullptr){
            bytehook_unhook(stubFunction);
            stubFunction = nullptr;
        }

        stubFunction = bytehook_hook_single(TARGET_ART_LIB,
                                              nullptr,
                                              getStringPrintfFunctionName(),
                                              reinterpret_cast<void *>(proxyStringPrintfFunc),
                                              nullptr,
                                              nullptr);

        if (stubFunction != nullptr) {
            __android_log_print(ANDROID_LOG_INFO, LOG_TAG_THREAD_SUSPEND_HOOK, "Hook setup success");
        }
    }

    bool checkFormat(const char *format) {
        return
                strstr(format, "timed out") != nullptr &&
                strstr(format, "state&flags") != nullptr &&
                strstr(format, "priority") != nullptr &&
                strstr(format, "barriers") != nullptr &&
                strstr(format, "ours") != nullptr &&
                strstr(format, "barrier value") != nullptr &&
                strstr(format, "nsusps") != nullptr &&
                strstr(format, "ncheckpts") != nullptr &&
                strstr(format, "thread_info") != nullptr;
    }
}
代码量不多,使用bytehook_hook_single单一hook方式,只 hook由libart.so调用StringPrintf函数的调用。
之后我们要从StringPrintf的可变参数中找到WrappedSuspend1Barrier类型的结构体指针,然后修改内部的原子变量。
本来我希望是这样的:
void *proxyStringPrintfFunc(const char *format, ...) {
        va_list args;
        va_start(args, format);
        __android_log_print(ANDROID_LOG_ERROR, LOG_TAG_THREAD_SUSPEND_HOOK_V15, "hit the hook point.");
        void *originCallback;
        if (checkFormat(format)) {
            using namespace kbArt;
            WrappedSuspend1Barrier *wrapped_barrier = va_arg(args, WrappedSuspend1Barrier*);
            if (wrapped_barrier != nullptr) {
                if (wrapped_barrier->barrier_.load() != 0) {
                    if (callbackObj != nullptr) {
                        // call the Java callback function.
                        NotifyHandleThreadSuspendTimeout::triggerSuspendTimeout(callbackObj);
                    }
                    // set the barrier to 0 to avoid the abort()...
                    wrapped_barrier->barrier_.store(0);
                    __android_log_print(ANDROID_LOG_ERROR, LOG_TAG_THREAD_SUSPEND_HOOK_V15, "set 0");
                }
            }
            originCallback = ((StringPrintf_t) originalStringPrintf)(format, args);
        } else {
            originCallback = ((StringPrintf_t) originalStringPrintf)(format, args);
        }
        va_end(args);
        return originCallback;
    }
直接wrapped_barrier->barrier_.store(0);,但是实际在模拟器上复现挂起,还是会崩溃,崩溃原因是,我们直接设置0这个标记位,立马告诉后续逻辑线程已经挂起成功了,之后在做比如需要挂起成功后的行为Thread.setName() & Thread.getAllStackTraces()内部还是会判断当前线程是不是已经挂起了,如果没有挂起则还是会崩溃。这里比较好理解,线程没有挂起成功,你去修改Thread的内部值,那么存在数据安全问题,这也是为什么线程挂起超时要进程终止的一个原因。
所以为了解决这个问题,我就在StringPrintf函数中自己起了一个循环,内部去自己检查wrapped_barrier->barrier的值是不是为 0。
当然为了性能,我这边使用nanosleep(&ts, nullptr);去释放 CPU 资源,这里的休眠时间可以自己调整,这边对等待时间也做了一个 JNI 回调,在业务侧做个埋点,根据埋点的时间最后算一个平均时间作为合理的休眠时间。

小知识点

  • 可变数组的读取。
va_list args; 
va_start(args, format);

va_arg(args, const char*); // func_name 
va_arg(args, int); // tid 
va_arg(args, const char*); // name.c_str() 
va_arg(args, int); // state_and_flags 
va_arg(args, int); // native_priority 
va_arg(args, void*); // first_barrier
// 即便不用也要先取出来然后才能轮到取下一个。
WrappedSuspend1Barrier *wrapped_barrier = va_arg(args, WrappedSuspend1Barrier*);
// 别忘记调用结束
va_end;
  • 保持内存模型一致。
由于我们拿不到系统中的WrappedSuspend1Barrier结构体,但是我们通过可变参数拿到的是一个指向 wrapped_barrier对象的指针,指针指向的是内存地址,所以我们只需要仿照Native侧的代码仿写一份即可。
// only for android 15+
// See Thread.tlsPtr_.active_suspend1_barriers below for explanation.
struct WrappedSuspend1Barrier {
    // TODO(b/23668816): At least weaken CHECKs to DCHECKs once the bug is fixed.
    static constexpr int kMagic = 0xba8;

    WrappedSuspend1Barrier() : magic_(kMagic), barrier_(1), next_(nullptr) {}

    int magic_;
    std::atomic<int32_t> barrier_;
    struct WrappedSuspend1Barrier *next_;
};

修复效果

Android 15 修复前崩溃时日志:

Android 15 修复后崩溃时日志:

打印当触发了abort()信号,到真正挂起后这段时间的耗时。

3总结


本文同样梳理了一下 Android 15在线程挂起流程的变更,看来每次升级都要绞尽脑汁想想 hook 方案,不过找到方案那一刻还是蛮开心的。
此次方案不一定最优解,因为这个循环等待操作不知道会不会带来 ANR风险,或者影响卡顿率指标等等,但是还是那句话避免 Crash 优先级更高。
同样,其实我们应用如果像拼多多整体流畅度高性能好,其实间接也不需要这种hook,这个只是没办法而为之。
最后,如果你有更好的方案请提出,或者本文有错误的地方请及时指出,万分感谢。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

从 XML 到 View 显示在屏幕上,都发生了什么?
核心交互场景优化黑科技,GC抑制从入门到精通
万字长文 · Android 功耗优化指导规范


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存