Android的离奇陷阱 — 设置线程优先级导致的微信卡顿惨案

Android上如果在主线程执行下面的代码:

Thread t = new Thread();t.start();t.setPriority(3);

我们的预期应该是子线程t的优先级被设置为了低优先级。

但真正运行后,我们惊奇的发现,不只是子线程t,主线程的优先级同样会被设置为低优先级!事实上,这三行代码甚至导致了Android微信客户端的一次线上故障!这是为什么?背后有怎样秘密?又如何管控和避免?我们来一起深入分析、研究下这个问题。

(传送门:如果不想深入了解这其中的原理,和一波三折的故事,可以直接跳到最后的 5.3 小节,那里提供了一些设置线程优先级的正确和错误的典型例子)

一、案件发生

微信Android客户端某个新版本发布之后,马上就收到了很多用户的反馈:公众号里的视频卡顿/音画不同步;朋友圈里的视频卡顿掉帧。看现象,初步可能会怀疑到是不是播放器有什么问题?甚至怀疑是不是CDN有什么问题?然而事情远没有想象的那么简单,在测试同学的帮助下,可以确定是Matrix的版本升级引起的。离谱!Matrix作为性能监控的工具,居然会影响到视频的播放?

二、案发现场

浏览器的同事先有了发现:音视频不同步,是因为pthread_cond_timedwait方法比设置的时间要长40ms左右导致。再经过测试,好家伙!不止是这个方法,新版本微信里,所有的sleep/wait方法,都要比原本设置的时间多出了40ms左右。可以用下面的代码,非常容易的复现:

long startTime = System.currentTimeMillis();Thread.sleep(10);Log.i("Matrix","duration = " + (System.currentTimeMillis() - startTime));//结果为:duration = 50

一开始我们完全没有头绪,根本不清楚到底是哪里的代码引起的,好吧,最蠢的办法,在新旧两个Matrix版本的commit中,不断二分,找到第一次出现问题的commit。终于找到了案发现场,定位到了类似这样的一个修改:

啊?只是设置线程优先级与启动线程的顺序调换,况且设置的也只是一个特定子线程的优先级,居然会有这么大的破坏力?没错,读者有兴趣可以在Android上运行这样一段代码:

Thread t = new Thread();t.start();t.setPriority(3);
long startTime = System.currentTimeMillis();Thread.sleep(10);Log.i("Matrix","duration = " + (System.currentTimeMillis() - startTime));  // 结果为duration = 50ms

非常离奇的现象就是:如果在Thread的start后马上调用setPriority,并且设置一个小于默认优先级(默认优先级为5)的优先级,就会出现sleep/wait等方法会比设置的时间多几十毫秒的现象。

而由于很多视频播放的逻辑中,都会通过系统的sleep/wait/pthread_cond_timedwait等方法来实现音视频的同步,会有非常频繁的调用,如果每次都多出几十毫秒,就会直接引起视频播放卡顿,音画不同步等问题,也就直接导致了这次微信的线上故障。

三、分析推理

离谱!只是想设置一个特定子线程的优先级,居然就直接影响了主线程主线程创建的所有子线程的sleep时间?这里绝对隐藏着什么秘密。

(在一起来探秘之前,需要补充一个小的背景(如果你已经充分了解Linux线程的nice值,可以直接跳过):不管是在Java层设置线程优先级,还是在Native层设置线程优先级,最终设置的,也是绝大部分情况下最终起到作用的,都是线程的nice值,nice值越高,也就说明这个线程的“脾气”越好,就越不容易抢到CPU,也就意味着线程的优先级越低。)

3.1. 罪魁祸首:TimerSlackHigh

既然Thread的start和setPriority的顺序改变会影响sleep/wait等方法的时间,我们就先看下setPriority做了什么事情,Thread.setPriority最终会调用到system/libartpalette/palette_android.cc里的PaletteSchedSetPriority方法:

palette_status_t PaletteSchedSetPriority(int32_t tid, int32_t managed_priority) {    if (managed_priority < art::palette::kMinManagedThreadPriority ||        managed_priority > art::palette::kMaxManagedThreadPriority) {        return PALETTE_STATUS_INVALID_ARGUMENT;    }    int new_nice = kNiceValues[managed_priority - art::palette::kMinManagedThreadPriority];     int curr_nice = getpriority(PRIO_PROCESS, tid);
    if (curr_nice == new_nice) {        return PALETTE_STATUS_OK;    }
    if (new_nice >= ANDROID_PRIORITY_BACKGROUND) {        SetTaskProfiles(tid, {"SCHED_SP_BACKGROUND"}, true);    } else if (curr_nice >= ANDROID_PRIORITY_BACKGROUND) {        SchedPolicy policy;        // Change to the sched policy group of the process.        if (get_sched_policy(getpid(), &policy) != 0) {            policy = SP_FOREGROUND;        }        SetTaskProfiles(tid, {get_sched_policy_profile_name(policy)}, true);    }
    if (setpriority(PRIO_PROCESS, tid, new_nice) != 0) {        return PALETTE_STATUS_CHECK_ERRNO;    }    return PALETTE_STATUS_OK;}
static const int kNiceValues[art::palette::kNumManagedThreadPriorities] = {    ANDROID_PRIORITY_LOWEST, // 1 (MIN_PRIORITY)    ANDROID_PRIORITY_BACKGROUND + 6,    ANDROID_PRIORITY_BACKGROUND + 3,    ANDROID_PRIORITY_BACKGROUND,    ANDROID_PRIORITY_NORMAL, // 5 (NORM_PRIORITY)    ANDROID_PRIORITY_NORMAL - 2,    ANDROID_PRIORITY_NORMAL - 4,    ANDROID_PRIORITY_URGENT_DISPLAY + 3,    ANDROID_PRIORITY_URGENT_DISPLAY + 2,    ANDROID_PRIORITY_URGENT_DISPLAY // 10 (MAX_PRIORITY)};

该方法会根据kNiceValues数组,把Java层Thread的优先级映射为Linux的线程nice值,上面的例子中Java层priority是3,kMinManagedThreadPriority的值是1,所以经过映射后,得到的nice值为13,而ANDROID_PRIORITY_BACKGROUND是10,表示后台优先级,原来我们设置了比后台优先级的nice值更高的值(即比后台优先级更低),此时系统会把该线程设置为后台线程,具体做了什么呢?接着看SetTaskProfiles

bool SetTaskProfiles(int tid, const std::vector<:string>& profiles, bool use_fd_cache) {    return TaskProfiles::GetInstance().SetTaskProfiles(tid, profiles, use_fd_cache);}
TaskProfiles::TaskProfiles() {    // load system task profiles    if (!Load(CgroupMap::GetInstance(), TASK_PROFILE_DB_FILE)) {        LOG(ERROR) << "Loading " << TASK_PROFILE_DB_FILE << " for [" << getpid() << "] failed";    }    //省略...... } 
static constexpr const char* TASK_PROFILE_DB_FILE = "/etc/task_profiles.json";

这里会去读取/etc/task_profiles.json这个配置文件,我们在这个配置文件里搜索这里传入的参数SCHED_SP_BACKGROUND,找到了这样的配置:

{    "Name": "SCHED_SP_BACKGROUND",    "Profiles": [ "HighEnergySaving", "LowIoPriority", "TimerSlackHigh" ]}

    {      "Name": "TimerSlackHigh",      "Actions": [        {          "Name": "SetTimerSlack",          "Params":          {            "Slack": "40000000"          }        }      ]    },    {      "Name": "TimerSlackNormal",      "Actions": [        {          "Name": "SetTimerSlack",          "Params":          {            "Slack": "50000"          }        }      ]    }

被设置为后台线程后,会设置三个profile:”HighEnergySaving”, “LowIoPriority”, “TimerSlackHigh“。

聪明的你应该敏锐的发现了这个TimerSlackHigh,“懒惰定时器”,名字看起来就非常可疑,很有可能跟我们在查的sleep/wait延迟有关,赶快查一下TimerSlack是什么:

原来真凶就是他!

TimerSlack是Linux系统为了降低系统功耗,避免timer时间参差不齐,过于的频繁的唤醒cpu,而设置的一种对齐策略。

总而言之:如果系统设置了大于等于10的nice值,即设置了比后台优先级还要低的优先级,即把线程设置成了后台线程,那么系统就会设置一个比较高的TimerSlack,从默认的50微秒,提高到40毫秒,从而导致wait/sleep等挂起的时间多了40ms左右。

但是,还是不对劲啊,我设置的明明只是特定子线程的优先级,按道理说只会影响该子线程的TimerSlack才对啊,为什么看起来影响了所有的线程呢?确实,这个问题还有很多疑点,我们接着分析。

3.2. 大误会:糊涂的setPriority

我们首先就会怀疑Java层调用setPriority,设置错了线程,那么找到 art/runtime/thread.cc

void Thread::SetNativePriority(int new_priority) {    palette_status_t status = PaletteSchedSetPriority(GetTid(), new_priority);    CHECK(status == PALETTE_STATUS_OK || status == PALETTE_STATUS_CHECK_ERRNO);}

在这里之后才会调用之前提到的PaletteSchedSetPriority方法,我们发现这里传递的参数也就是设置优先级的对象线程是GetTid(),继续找到GetTid()

pid_t GetTid() const {    return tls32_.tid;}

tls32_是用来描述native线程的数据结构,继续找下tls32_.tid是在哪里赋值的,art/runtime/thread.cc

void Thread::InitTid() {    tls32_.tid = ::art::GetTid();}
bool Thread::Init(ThreadList* thread_list, JavaVMExt* java_vm, JNIEnvExt* jni_env_ext) {    //......    InitTid();    //......}
void* Thread::CreateCallback(void* arg) {    //......    CHECK(self->Init(runtime->GetThreadList(), runtime->GetJavaVM(), self->tlsPtr_.tmp_jni_env));    //......}
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {    //......    pthread_create_result = pthread_create(&new_pthread,                                           &attr,                                           Thread::CreateCallback,                                           child_thread);    //......}

一层一层往上找,来到CreateNativeThread方法,原来是在线程创建的时候在pthread_create的回调方法CreateCallback里面调用的InitTid,来设置的。而CreateNativeThread是Thread.start调用来创建native线程的地方。

对!你可能已经想到了,这里是有时序问题的,pthread_create的回调CreateCallback是异步执行的,所以start执行完毕,并不能确保setNativePriority的tid参数已经赋值,而一旦tid还未被赋值,那么这个时候调用setPriority,tid就是默认值0

我们再看如果Native的系统调用setpriority的参数如果是0的话,会出现什么情况:

来了,如果第二个参数tid是0的话,就会设置调用线程的优先级!

那TimerSlack呢?在tid为0的情况下的表现是什么样呢?我们找到最终设置TimerSlack的地方,system/core/libprocessgroup/task_profiles.cpp

bool SetTimerSlackAction::ExecuteForTask(int tid) const {    //......    if (tid == 0 || tid == GetThreadId()) {        if (prctl(PR_SET_TIMERSLACK, slack_) == -1) {            PLOG(ERROR) << "set_timerslack_ns prctl failed";        }    }
    return true;}

TimerSlack最终是通过系统调用prctl方法设置的,如果tid是0,同样会设置当前线程的TimerSlack。

所以结论是,如果GetTid为0,那么无论是nice值还是TimerSlack都会对当前的调用线程设置。

居然有这么诡异的陷阱!我们再回头看引起问题的代码,似乎就说得过去了:

 Thread t = new Thread(); t.start(); t.setPriority(3);
 long startTime = System.currentTimeMillis(); Thread.sleep(10); Log.i("Matrix","duration = " + (System.currentTimeMillis() - startTime));

t.start()调用之后,在native的tid还没有被设置好的时候,就执行了下面的t.setPriority,这时GetTid返回值是0,native的setpriority的tid参数为0,就会把调用线程(Calling Thread)即主线程的nice设置为13,同时TimerSlack也被设置为40ms。有这样的结果就说得通了。

然而,结束了吗?因为时序问题,主线程被设置了高nice和高TimerSlack,理所当然的,主线程创建的子线程的nice值和TimerSlack理所当然地继承了父线程,也就是主线程,所以自然也被影响了,是这样吗?

用这样一段代码测试:

Thread t = new Thread();t.start();t.setPriority(3);
long startTime = System.currentTimeMillis();Thread.sleep(10);Log.i("Matrix","MainThread duration = " + (System.currentTimeMillis() - startTime));  //结果为50
new Thread(new Runnable() {    @Override    public void run() {        long startTime = System.currentTimeMillis();        Thread.sleep(10);        Log.i("Matrix","Thread duration = " + (System.currentTimeMillis() - startTime));          //结果为10    }}).start();

我们发现,主线程被错误设置nice值和TimerSlack后,创建的子线程并没有继承主线程的nice值和TimerSlack,这又是为什么呢?我们继续分析。

在native层的线程,的确子线程会继承父线程也就是主线程的nice值和TimerSlack,但是,start和priority的时序问题,只会错误地设置主线程的native的nice值和TimerSlack,并不会影响主线程Java层的priority变量,而子线程同样会在Java层继承父线程Java层的priority,而在native层创建线程的时候,有这样的逻辑:

void* Thread::CreateCallback(void* arg) {    //......    ArtField* priorityField =         jni::DecodeArtField(WellKnownClasses::java_lang_Thread_priority);    self->SetNativePriority(priorityField->GetInt(self->tlsPtr_.opeer));    //......}

在native创建线程调用到CreateCallback时,会根据Java层的priority,重新设置native层的priority/nice值和TimerSlack,而在上面的情况中,主线程的Java层priority并没有被设置,是默认的5,对应的nice值为0,所以子线程的nice值就被再次设置为了0,也就重新被设置为了前台线程,TimerSlack就又被设置为了低TimerSlack。

那么,问题又来了,既然主线程因为时序问题被错误地设置后台优先级后,并不影响其创建的子线程的nice值和TimerSlack,而线上故障中,引起音画不同步和视频掉帧的线程,却又都是在主线程创建的子线程中产生的。那又是什么让本来只会影响主线程的问题,进一步污染了它创建的全部子线程呢?

3.3. 帮凶:WebView推波助澜

再经过进一步排查,发现一个非常奇怪的现象,来看下面的代码:

Thread t = new Thread();t.start();t.setPriority(3);
long startTime = System.currentTimeMillis();Thread.sleep(10);Log.i("Matrix","MainThread duration = " + (System.currentTimeMillis() - startTime));  //结果为50
new WebView(context);
new Thread(new Runnable() {    @Override    public void run() {        long startTime = System.currentTimeMillis();        Thread.sleep(10);        Log.i("Matrix","Thread duration = " + (System.currentTimeMillis() - startTime));          //结果为50    }}).start();

考验眼力的时候来了,发现了跟之前的测试代码有什么区别了吗?是的,只要在创建子线程前加一句new WebView(context),那么子线程的nice和TimerSlack就也被影响了!

事实上,微信在某些页面的确会有预加载webview的逻辑,我们也会发现,也的确在new了WebView之后,设置优先级的时序错误,就不止会影响主线程,而是其创建的所有的子线线程就都会被污染,都有了高TimerSlack,这是为什么呢?

我们在new Webview前后打印一下主线程nice值就会发现,主线程nice值在执行new WebView之前是13,之后变成了-4。

哦?new WebView居然会设置主线程的优先级?

找到Chromium的源码content/browser/browser_main_loop.cc

// Up the priority of the UI thread unless it was already high (since Mac// and recent versions of Android (O+) do this automatically).if (base::FeatureList::IsEnabled(    features::kBrowserUseDisplayThreadPriority) &&    base::PlatformThread::GetCurrentThreadPriority() <          base::ThreadPriority::DISPLAY) {    base::PlatformThread::SetCurrentThreadPriority(        base::ThreadPriority::DISPLAY);}

其中ThreadPriority::DISPLAY的值就是-4,是的,Chromium有这样的逻辑:如果当前主线程的nice值大于-4(即优先级较低),就会提高主线程的优先级,并且这里设置优先级是直接调用了native的setpriority,所以并不会同时修改TimerSlack

这个操作其实也很容易理解,是Chromium的保护措施,它不希望渲染UI的主线程处在一个较低的优先级,不过却阴差阳错地成为了帮凶。

我们仔细看下,native线程在创建的时候根据Java层的priority设置nice值的时候的逻辑:

if (new_nice >= ANDROID_PRIORITY_BACKGROUND) {    SetTaskProfiles(tid, {"SCHED_SP_BACKGROUND"}, true);} else if (curr_nice >= ANDROID_PRIORITY_BACKGROUND) {    SchedPolicy policy;    // Change to the sched policy group of the process.    if (get_sched_policy(getpid(), &policy) != 0) {        policy = SP_FOREGROUND;    }    SetTaskProfiles(tid, {get_sched_policy_profile_name(policy)}, true);}

如果新设置的nice值大于等于10,直接判断为后台线程,设置高的TimerSlack;但另一方面,只有当前的nice值大于10,新的nice值小于10,才会把线程设置为前台线程,设置低的TimerSlack

而执行new WebView之后,主线程nice值是-4,而子线程继承了主线程的nice值也是-4,-4 < 10,系统认为你已经是前台线程了,就不会走到else if的逻辑,也就不会重新把线程设置为前台线程,也就不会设置低TimerSlack,而子线程从主线程继承的TimerSlack就是高TimerSlack。从而,主线程创建的全部子线程就都被污染了,也就引起了,音画不同步和掉帧的故障。

至此才真正破案了,所有的疑问和“奇怪”现象也都可以解释通了。

该问题一开始是由Thread的start和setPriority的时序问题,导致主线程被设置为后台线程,同时被设置了高TimerSlack;而帮凶Chromium又在初始化WebView的时候默默地把主线程的nice值设置成了较低的nice值(较高的优先级),但又没有设置回低TimerSlack,从而主线程创建的子线程继承了主线程的nice值和高TimerSlack后,却认为自己已经是前台线程,所以也没有机会根据Java层的priority重新设置低TimerSlack,最后就导致了主线程连同其创建的所有子线程的TimerSlack全部都被设置为了高TimerSlack,从而产生了一系列的问题,导致了这次故障。

四、监控机制:

原理已经搞清楚后,我们需要建立一个监控机制,避免之后再出现这种情况。

首先我们需要确保住主线程优先级不被设置的过低,hook系统调用setpriority,如果对主线程设置过低的优先级(过高的nice值),则直接报错:

int (*original_setpriority)(int __which, id_t __who, int __priority);
int my_setpriority(int __which, id_t __who, int __priority) {        if (__priority <= 0) {        return original_setpriority(__which, __who, __priority);    }        if (__who == 0 && getpid() == gettid()) {        // Throw Crash Here    } else if (__who == getpid()) {        // Throw Crash Here    }
    return original_setpriority(__which, __who, __priority);}

另外,我们还hook了设置TimerSlack的prctl方法,确保主线程的TimerSlack值不被设置的过大:

int (*original_prctl)(int option, unsigned long arg2, unsigned long arg3,              unsigned long arg4, unsigned long arg5);
int my_prctl(int option, unsigned long arg2, unsigned long arg3,             unsigned long arg4, unsigned long arg5) {    if(option == PR_SET_TIMERSLACK) {        if (gettid()==getpid() && arg2 > 50000) {            // Throw Crash Here        }    }    return original_prctl(option, arg2, arg3, arg4, arg5);}

监控会在微信各种体验版和debug的时候打开,确保当再次出现主线程的优先级或者TimerSlack被错误设置的情况时,能够提前发现,避免这类问题被带到线上。

五、额外的结论:

最后,我们再讨论下,在设置优先级的时候我们容易出现的一些错误。

5.1. 线程优先级的“双标”

Thread在Java层的优先级与Native层或者说Linux系统层的线程优先级,也就是nice值,是两套不同的标准,数字大小的意义甚至也是相反的,容易产生混淆和误用。

通过Thread.setPriority方法设置的优先级是在Java层的优先级,数字从0到10,数字越大表优先级越高,默认是5,Android主线程默认是5。Java层的优先级通过native层的kNiceValues数组,映射为nice值,再起到作用。

通过android.os.Process.setThreadPriority方法设置的优先级是Native层的线程优先级/nice值,数字从-20到20,数字越大代表优先级越低,默认是0,Android主线程默认的nice值是-10。另外,native层的系统调用setpriority当然也是直接设置的nice值。

5.2. 正确设置HandlerThread的优先级

怎么设置HandlerThread的优先级呢?第一反应可能会这样写

HandlerThread ht = new HandlerThread("leafjia-thread");ht.setPriority(3);ht.start();

似乎也没什么问题哦?但是这样设置,其实除了设置了Java层Thread对象的成员变量priority,并不会起到任何其他的效果。

我们看HandlerThread的源码:

public class HandlerThread extends Thread {    int mPriority;    //......        public HandlerThread(String name) {        super(name);        mPriority = Process.THREAD_PRIORITY_DEFAULT;    }        public HandlerThread(String name, int priority) {        super(name);        mPriority = priority;    }
    //......
    @Override    public void run() {        mTid = Process.myTid();        Looper.prepare();        synchronized (this) {            mLooper = Looper.myLooper();            notifyAll();        }        Process.setThreadPriority(mPriority);        onLooperPrepared();        Looper.loop();        mTid = -1;    }    //......}

调用ht.serPriority(3)其实设置的是其父类Thread的priority成员变量,而HandlerThread本身有自己的mPriority成员变量,start之后,会在创建Native Thread的时候,在调用run回调方法前,根据Java层Thread的priority(我们已经设置为了3)设置Native的nice值,这时的确优先级能够设置成功。但是HandlerThread自己重写了run方法,在之后执行的run方法中,又再次通过Process.setThreadPriority(mPriority)设置了自己的优先级为mPriority,而mPriority并不能通过Thread.setPriority方法设置。所以上面的代码并不生效。

正确的方法也显而易见:

HandlerThread ht = new HandlerThread("My-Thread", 13);ht.start();

需要注意的是,因为线程优先级最终是通过Process.setThreadPriority方法实现的,所以priority使用的是-20到20的nice值的优先级体系。

5.3. Some Cases

进一步,可以总结出下面几种设置线程优先级的case,如果我们的目的是设置thread线程的优先级为3(而不想改变调用线程的优先级),即nice值为13,那么:

//Case1: 正确,thread线程的nice值被设置为13Thread thread = new Thread();thread.setPriority(3);thread.start();
//Case2: 正确,thread线程的nice值被设置为13Thread thread = new Thread(new Runnable() {    @Override    public void run() {        Thread.currentThread().setPriority(3);        // 或:        // Process.setThreadPriority(13);    }});thread.start();
//Case3: 错误,thread线程的nice值一定被设置为13//(可能直接被设置或从当前线程继承而来,视时序而定),// 当前线程的nice值很有可能被设置为13Thread thread = new Thread();thread.start();thread.setPriority(3);
//Case4: 错误,同上HandlerThread thread = new HandlerThread("My-Thread");thread.start();thread.setPriority(3);
//Case5: 错误,thread线程的nice值被设置为默认的0HandlerThread thread = new HandlerThread("My-Thread");thread.setPriority(3);thread.start();
//Case6: 正确,thread线程的nice值被设置为13HandlerThread thread = new HandlerThread("My-Thread", 13);thread.start();

至此,就是本文的全部内容。线程优先级的相关问题,大家平时可能关注的并不多,或者会认为线程优先级的影响并不会特别大。而看起来平平无奇的设置线程优先级的三行代码,就真的引起了一次线上故障。这也提醒大家,一旦涉及到多线程,就一定要对时序问题特别谨慎。

文章来源于互联网:Android的离奇陷阱 — 设置线程优先级导致的微信卡顿惨案

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.dandroid.cn/archives/16094,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?