应用卡顿优化

对于卡顿分析,首先需要明确分析的因素,即影响卡顿有哪些点,是 CPU 繁忙,线程锁资源导致的阻塞,IO 耗时操作,还是内存吃紧等,然后再收集卡顿时相关信息,当前设备信息,当前运行状态,堆栈信息等,最后根据这些信息,以及结合具体的业务场景得到卡顿原因。

1.卡顿现象

对于卡顿现象,最直观体现在应用显示上。正常一个 App 不卡顿时,应该表现出页面显示比较稳定,没有跳变,丢帧;动画平稳流畅;用户交互反应灵敏。

通常条件下,人眼的识别连贯图像的速度是 24 帧/s,也就是 1000ms/24 帧,大约为 40ms。达到或者超过这个速度的连贯图像,观看时就不会形成卡顿的感觉。形成这个现象的原因是因为人眼观看影像时,会产生视觉延迟导致的。所以说,我们经常说人眼的视觉延迟感应速度为>=40ms。

游戏玩家通常追求更流畅的游戏画面体验一般要达到 60FPS 以上,但我们平时看到的大部分电影或视频 FPS 其实不高,一般只有 25FPS ~ 30FPS,而实际上我们也没有觉得卡顿。

一般的手机刷新率都在 60Hz(16.7ms),目前很多安卓手机的屏幕刷新率已经达到 90Hz(11.1ms),甚至一些旗舰系列已经有 120Hz(8.3ms),屏幕刷新率是有屏幕决定的。如果一帧渲染的时间超出屏幕刷新间隔时间,就会出现丢帧,但即使掉几帧用户也不感觉到。对于页面显示在掉帧严重时用户才会感觉卡顿。所以目前手机上的高刷新率屏幕远高于 FPS,相当于提高了画面 FPS 的上限。

2.卡顿影响因素

卡顿影响因素主要包含两个方面,一方面是来自系统,如手机硬件,CPU、屏幕刷新率等;另一方面是应用自身的问题。

2.1 CPU

现在手机芯片的性能越来越高,像苹果 A14 已经达到 5nm,最大频率接近 3GHz。计算能力越来越强,是否意味着不会出现卡顿呢?答案是否定的,因为现在的 App 体积越来越大,功能越来越大,对计算能力,内存占用也越来越高,所以当很多 app 都在运行时,就有可能因为 CPU 『忙不过来』出现卡顿。

目前的手机 CPU 按照核心数和架构,可以分为三类:

  • 非大小核架构
  • 大小核架构
  • 大中小核架构

小核心一般来说主频低,功耗也低,使用的一般是 arm A5X 系列,比如高通骁龙 845,小核心是由四个 A55 (最高主频 1.8GHz ) 组成

大核心一般来说最高主频比较高,功耗相对来说也会比较高,使用的一般是 arm A7X 系列,比如高通骁龙 845,大核心就是由四个 A75(最高主频 2.8GHz)组成

手机芯片架构上分为大小核和主要是为了能够满足在不同计算任务的前提下,尽可能降低功耗,毕竟手机上由电池来供电。

CPU 核心的使用是有一些方式来将不同的任务跑在不同的核心上的,叫做『绑核』。顾名思义就是把某个任务绑定到某个或者某些核心上,来满足这个任务的性能需求,如:

  • 任务本身负载比较高,需要在大核心上面才能满足时间要求
  • 任务本身不想被频繁切换,需要绑定在某一个核心上面
  • 任务本身不重要,对时间要求不高,可以绑定或者限制在小核心上面运行

目前 Android 中绑核操作一般是由系统来实现的,常用的有三种方法:

  • 配置 CPUset:使用 CPUset 子系统可以限制某一类的任务跑在特定的 CPU 或者 CPU 组里面,比如下面,Android 中会划分一些默认的 CPU 组,厂商可以针对不同的 CPU 架构进行定制
  • 配置 affinity:使用 affinity 也可以设置任务跑在哪个核心上
  • 系统调度算法:在 Linux 调度算法中修改调度逻辑,也可以让指定的 task 跑在指定的核上面,部分厂家的核调度优化就是使用的这种方法

还有一些其他不常规的方式:比如锁频。当手机运行负载长时间高度,此时温度也会很高,此时如果继续保持高负载情况,可能导致手机关机以保护硬件,所以在 CPU 调度时,一般在这种情况下会做降频处理,而锁频就是使 CPU 核心不降频,以保持运行性能。

大概了解了 CPU 的一些情况,那么在 app 出现问题排查时,或者监测时,拿到 CPU 运行的信息,CPU 负载,大小核心运行频率,用户运行时间,系统运行时间等,能够有效帮助定位问题。

CPU 信息获取方式:

// 获取 CPU 核心数
cat /sys/devices/system/cpu/possible  

// 获取某个 CPU 最大频率
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq

// 获取某个 CPU 当前频率(不一定有权限)
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq

2.2 帧率

对于普通的 App 来说,其实帧率不能算是影响卡顿的因素。配置高刷新率的屏幕,相应的使用的也是比较好的芯片,手机厂商会平衡这一点,不会在中低端手机上搭配一块高端屏幕,因为刷新间隔时间缩短,对 CPU、GPU 计算的能力要求也高,否则很容易出现掉帧情况。对于一些游戏应用和 VR/AR 等应用,刷新时间间隔短的情况下,本身由于计算渲染任务量很大,导致 CPU 和 GPU 『过忙』。

截屏2021-05-15 上午11.47.53.png
截屏2021-05-15 上午11.47.59.png

由于采用了 VSync(垂直同步)信号机制,一旦收到 VSync 信号(时间间隔屏幕刷新间隔,如 60Hz屏幕为 16ms 触发一次),CPU 和 GPU 就开始计算和渲染。当 CPU 过忙或者在处理其他任务时,就可能导致不能及时完成计算导致出现丢帧的情况。

Android API 中 Choreographer 负责根据接收到的 VSync 信号来进行绘制,即会走 ViewRootImpl 的 scheduleTraversals() 方法。

Choreographer 中暴露的 postFrameCallback() 通常用来计算丢帧情况。

// Application.java
public void onCreate() {
    super.onCreate();
    //在Application中使用postFrameCallback
    Choreographer.getInstance().postFrameCallback(new  FPSFrameCallback(System.nanoTime()));
}

2.3 应用自身因素

对于上述因素,应用开发者是不能改变的,但对于了解这些因素也同样重要,只有了解系统的一些特性,限制条件,才能更容易较少在应用开发时卡顿的出现。多数情况下,卡顿更多的来自于应用本身,比如主线程耗时操作,锁竞争导致的阻塞,UI 和 动画等过于复杂,常见导致 ANR 问题的操作等

3 卡顿排查工具

3.1 adb 获取常用信息

1 adb 内存数据采集

使用 adb shell "dumpsys meminfo -s <pakagename | pid>"命令,输出结果分以下4部分:

  • process 以进程的PSS从大到小依次排序显示,每行显示一个进程;
  • OOM adjustment 分别显示每类的进程情况
  • category 以Dalvik/Native/.art mmap/.dex map等划分的各类进程的总PSS情况
  • total 总内存、剩余内存、可用内存、其他内存

2 adb fps(每秒帧数,计算流畅度)数据采集

adb命令:adb shell dumpsys gfxinfo <package | pid>

3 CPU

CPU 占用率:有两种方法可以获取
1) adb shell "top -n 5 | grep <package | pid>" 实时监控的CPU占用率(-n 指定执行次数

2) adb shell "dumpsys cpuinfo | grep <package | pid>"

两种方法直接区别在于,top是持续监控状态,而 dumpsys cpuinfo 获取的实时 CPU 占用率数据

CPU 频率:
// 获取 CPU 核心数
cat /sys/devices/system/cpu/possible  

// 获取某个 CPU 最大频率
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq

// 获取某个 CPU 当前频率(不一定有权限)
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_cur_freq

CPU 温度:
cat /sys/class/thermal/thermal_zone*/type

4 GPU

Gpu使用率获取:会得到两个值,(前一个/后一个)*100%=使用率
adb shell cat /sys/class/kgsl/kgsl-3d0/gpubusy

Gpu工作频率:
adb shell cat /sys/class/kgsl/kgsl-3d0/gpuclk
adb shell cat /sys/class/kgsl/kgsl-3d0/devfreq/cur_freq

Gpu最大、最小工作频率:
adb shell cat /sys/class/kgsl/kgsl-3d0/devfreq/max_freq
adb shell cat /sys/class/kgsl/kgsl-3d0/devfreq/min_freq

Gpu可用频率
adb shell cat /sys/class/kgsl/kgsl-3d0/gpu_available_frequencies
adb shell cat /sys/class/kgsl/kgsl-3d0/devfreq/available_frequencies

3.2 Traceview

它利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中。Traceview 可以用来查看整个过程有哪些函数调用,但是工具本身带来的性能开销过大,有时无法反映真实的情况。比如一个函数本身的耗时是 1 秒,开启 Traceview 后可能会变成 5 秒,而且这些函数的耗时变化并不是成比例放大。在 Android 5.0 之后,新增了 startMethodTracingSampling 方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。

Debug.startMethodTracing("demo");
Debug.stopMethodTracing();

3.3 systrace

systrace 是 Android 4.1 新增的性能分析工具。通常使用 systrace 跟踪系统的 I/O 操作、CPU 负载、Surface 渲染、GC 等事件。systrace 利用了 Linux 的 ftrace 调试工具,相当于在系统各个关键位置都添加了一些性能探针,也就是在代码里加了一些性能监控的埋点。Android 在 ftrace 的基础上封装了 atrace,并增加了更多特有的探针,例如 Graphics、Activity Manager、Dalvik VM、System Server 等。systrace 工具只能监控特定系统调用的耗时情况,性能开销非常低。但是它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。由于系统预留了 Trace.beginSection 接口来监听应用程序的调用耗时,可以通过编译时给每个函数插桩的方式来实现,也就是在重要函数的入口和出口分别增加 Trace.beginSection 和 Trace.endSection。当然出于性能的考虑,需要过滤大部分指令数比较少的函数,这样就实现了在 systrace 基础上增加应用程序耗时的监控。通过这样方式的好处有:可以看到整个流程系统和应用程序的调用流程。包括系统关键线程的函数调用,例如渲染耗时、线程锁,GC 耗时等。

  • 代码方式:
import android.os.Trace;

Trace.beginSection(String sectionName)
Trace.EndSection()
  • 命令行方式

Android SDK 中提供了 Python 脚本,用来抓取 systrace。位置:Android/sdk/platform-tools/systrace

python systrace.py [options] [category1] [category2] ... [categoryN]

输出全部信息

python systrace.py -b 32768 -t 5 -o mytrace.html gfx input view webview wm am sm audio video camera hal app res dalvik rs bionic power sched irq freq idle disk mmc load sync workq memreclaim regulators

systrace 生成的也是 HTML 格式的结果,可以通过 Chrome 打开查看可视化信息。打开 chrome://tracing/,加载 trace 文件。

截屏2021-05-15 下午4.56.36.png

3.4 Simpleperf

如果想分析 Native 函数调用,可以使用 Simpleperf。Android 5.0 新增了 Simpleperf 性能分析工具,它利用 CPU 的性能监控单元(PMU)提供的硬件 perf 事件。使用 Simpleperf 可以看到所有的 Native 代码的耗时,有时候一些 Android 系统库的调用对分析问题有比较大的帮助,例如加载 dex、verify class 的耗时等。Simpleperf 同时封装了 systrace 的监控功能,通过 Android 几个版本的优化,现在 Simpleperf 比较友好地支持 Java 代码的性能分析。具体来说分几个阶段:

  • 第一个阶段:在 Android M 和以前,Simpleperf 不支持 Java 代码分析。
  • 第二个阶段:在 Android O 和以前,需要手动指定编译 OAT 文件。
  • 第三个阶段:在 Android P 和以后,无需做任何事情,Simpleperf 就可以支持 Java 代码分析。

从这个过程可以看到 Google 还是比较看重这个功能,在 Android Studio 3.2 也在 Profiler 中直接支持 Simpleperf。

3.5 可视化工具

在 Android Studio 3.2 的 Profiler 中直接集成了几种性能分析工具,其中:

  • Sample Java Methods 的功能类似于 Traceview 样本方式
  • Trace Java Methods 的功能类似于 Traceview 全部函数调用采集
  • Trace System Calls 的功能类似于 systrace
  • SampleNative (API Level 26+) 的功能类似于 Simpleperf

Perfetto 是 Android 10 中引入的全新平台级跟踪 trace 收集和分析工具。适用于 Android、Linux 和 Chrome 的更加通用和复杂的开源跟踪项目。与 Systrace 不同,它提供数据源超集,以 protobuf 编码的二进制流形式记录任意长度的跟踪记录。可以在 Perfetto 界面中打开这些跟踪记录。

截屏2021-05-15 下午5.03.51.png

4 卡顿监测

4.1 主线程监控

上述提到的几种方式主要是帮助我们分析卡顿问题,但是首先需要有数据才行,就需要进行信息采集。

Android 系统的 UI 操作都是在主线程中完成的,所以对于卡顿问题监测,可以在主要监测主线程中的消息执行情况。

业界有几种常见解决方案,都可以从一定程度上,帮助开发者快速定位到卡顿的堆栈,如 BlockCanary、ArgusAPM、LogMonitor 。这些方案的主要思想是,监控主线程执行耗时,当超过阈值时,上报当前主线程的执行堆栈,通过堆栈分析找到卡顿原因。

从监控主线程的实现原理上,主要分为两种:

  • 依赖主线程 Looper,监控每次 dispatchMessage 的执行耗时。(BlockCanary)
  • 依赖 Choreographer 模块,监控相邻两次 Vsync 事件通知的时间差。(ArgusAPM、LogMonitor)

第二种方案,利用系统 Choreographer 模块,向该模块注册一个 FrameCallback 监听对象,同时通过另外一条线程循环记录主线程堆栈信息,并在每次 Vsync 事件 doFrame 通知回来时,循环注册该监听对象,间接统计两次 Vsync 事件的时间间隔,当超出阈值时,取出记录的堆栈进行分析上报。

简单代码实现如下:

Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
   @Override    
   public void doFrame(long frameTimeNanos) {
       if(frameTimeNanos - mLastFrameNanos > 100) {
           // 上报堆栈等信息
       }
       mLastFrameNanos = frameTimeNanos;
       Choreographer.getInstance().postFrameCallback(this);
   }
});

4.2 BlockCanary

blockcanary_flow.png

这里主要分析下 BlockCanary,主要是给主线程的 Looper 设置一个 Printer,

        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }

            // This must be in a local variable, in case a UI event sets the logger
            Printer logging = me.mLogging;
            if (logging != null) {
                logging.println(">>>>> Dispatching to " + msg.target + " " +
                        msg.callback + ": " + msg.what);
            }
            msg.target.dispatchMessage(msg);
            if (logging != null) {
                logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
            }
            ...
        }

Printer 接口中方法 println,需要知道 Message 执行之前还是之后,用一个 mPrintingStarted 记录。然后通过比较前后的时间差,来判定是否是卡顿。这里判定条件可以增加其他维度,如不同机型,如高端机还是低端机,设置不同的阈值。同时不同阈值的设置对于ANR 的监测也更加精选,不仅仅限于系统的设置的一些阈值。

    @Override
    public void println(String x) {
        if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
            return;
        }
        if (!mPrintingStarted) {
            mStartTimestamp = System.currentTimeMillis();
            mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
            mPrintingStarted = true;
            startDump();
        } else {
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            if (isBlock(endTime)) {
                notifyBlockEvent(endTime);
            }
            stopDump();
        }
    }

当满足卡顿的判定后,开始采集信息,然后将信息保存到本地日志中,也可以进行上报。

dump 信息包括:

  • 基本信息:安装包标示、机型、api 等级、uid、CPU 内核数、进程名、内存、版本号等
  • 耗时信息:实际耗时、主线程时钟耗时、卡顿开始时间和结束时间
  • CPU 信息:时间段内 CPU 是否忙,时间段内的系统CPU/应用CPU占比,I/O 占 CPU 使用率
  • 堆栈信息:发生卡慢前的最近堆栈,可以用来帮助定位卡慢发生的地方和重现路径
  • 无法监测函数的执行耗时情况

存在的问题:

  • 获取堆栈信息,直觉上获取主线程堆栈不耗时,但是事实上获取堆栈的代价是巨大的,它要暂停主线程的运行。
  • 此外 print 中需要大量的字符串拼接,导致性能下降,特别是在阈值设置的较小的情况下,拼接操作更加频繁。
  • 高版本的一些机型没有权限拿到 CPU 信息
  • 堆栈漂移:当检测到一个消息耗时时,采集堆栈信息,而这个消息中前面的堆栈是耗时的,后面的堆栈不耗时,采集时如果仅仅拿到后面的堆栈,就会产生误差

【Android 开发高手课】中作者提到一个改进方案:
每隔 1 秒向主线程消息队列的头部插入一条空消息。假设 1 秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在 0~1 秒之间。换句话说,如果我们需要监控 3 秒卡顿,那在第 4 次轮询中头部消息依然没有被消费的话,就可以确定主线程出现了一次 3 秒以上的卡顿。

message_insert.png

SDK 改进:高本版 SDK 中增加了 Message 执行慢判定阈值的设置方法,但是该方法对开发者不可见,可以通过反射进行设置。

/**
 * Set a thresholds for slow dispatch/delivery log.
 * {@hide}
 */
public void setSlowLogThresholdMs(long slowDispatchThresholdMs, long slowDeliveryThresholdMs) {
    mSlowDispatchThresholdMs = slowDispatchThresholdMs;
    mSlowDeliveryThresholdMs = slowDeliveryThresholdMs;
}

4.3 插桩方案

上述提到 BlockCanary 不能够监测函数的执行耗时,要想监测函数的耗时情况,可以利用 systrace 预留的 Trace.beginSection 和 Trace.endSection() 接口来监听应用程序的调用耗时。比较好的方案是不用手动在需要监测函数处添加,而是通过插桩方式自动添加。利用 Java 字节码修改工具(如 ASM、Javassis等),在编译期间收集所有生成的 class 文件,扫描文件内的方法指令进行统一的打点插桩,同样也可以高效的记录函数执行过程中的信息。

其中 Matrix 对于卡顿的监测部分就是利用插桩方案,Matrix 是一款微信研发并日常使用的应用性能接入框架,支持 IOS, MacOS 和 Android。 Matrix 通过接入各种性能监控方案,对性能监控项的异常数据进行采集和分析,输出相应的问题分析、定位与优化建议,从而帮助开发者开发出更高质量的应用。

以下是一些热门方案的对比:

截屏2021-05-15 下午9.22.33.png

5 参考

Profile your app performance

Android Systrace 基础知识 - CPU Info 解读

性能工具Systrace

Matrix TraceCanary

BlockCanary — 轻松找出Android App界面卡顿元凶

Android性能测试(内存、cpu、fps、流量、GPU、电量)——adb篇

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容

  • 概要 应用运行时的卡顿问题非常影响用户体验,严重降低产品表现力,本文将介绍应用卡顿原因以及分析方法等等。 卡顿问题...
    某昆阅读 2,526评论 1 8
  • 最近在极客时间学习,做一下总结。 Android卡顿排查工具 常用的排查工具分为两种类型: instrument类...
    affyzh阅读 1,517评论 0 1
  • 1. 工具选择 CPU Profiler、Systrace、StrictMode 原因复杂:代码、内存、绘制、IO...
    perry_Fan阅读 2,216评论 2 15
  • 卡顿是非常直观的用户体验,它的特点是:产生原因错综复杂,线上问题难以复现。基于这个特点,卡顿优化主要是三方面工作:...
    Stan_Z阅读 16,031评论 6 30
  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,520评论 28 53