优化 Android 应用需要深入了解并发工作流和协程挂起。
虽然本地性能分析可提供有用的快照,但如何在不产生开销的情况下将这些数据分析扩展到生产环境仍然是一项挑战。
本讲座将提供一个持续性能反馈环的架构蓝图。
androidx.tracing 2.0:动态切换后端,并在线程之间传播协程上下文(Perfetto 流程)。
智能插桩:使用本地 ART 跟踪功能查找热点,然后自动注入低开销跟踪点。
宏观基准测试:在 CI 环境中跟踪回归问题并验证实体设备上的性能提升。
现场跟踪:使用 ProfilingManager,超越本地和 CI 跟踪。
traceCoroutine 跟踪异步执行,也可以将传播令牌传递到基于回调的代码中。转写文稿
Rahul Ravikumar
大家下午好!
我叫 Rahul。
我是 Android 开发者体验团队的一名工程师。
我专注于应用和库性能,本次演讲的主题是跟踪。
在深入探讨跟踪之前,我想先介绍一些常用词汇,以便您能跟上进度。
因此,在本演讲中,我提到的跟踪都是指显式跟踪,即手动插桩代码,以便了解应用在做什么。
我说的不是我将要介绍的自动性能分析器。
因此,手动跟踪是一门在代码库中留下痕迹的艺术,以便您在日后遇到性能问题或任何其他问题时,能够据此进行推理。
通常比日志好得多。
我不知道您是否看过大型应用的 Logcat 输出。
它通常是一堵巨大的文字墙,很难理解。
另一方面,跟踪则要好得多。
这样更容易直观呈现。
您有持续时间。
您已经有了一些结构。
您有一些工具,甚至可以进行切片和切块,准确了解何时发生了什么。
因此,这种方式要好得多。
您可以让智能体查看跟踪记录,并提出问题。
您可以使用 Trace Processor、基准等工具,甚至可以将跟踪记录转换为指标。
这些都是您可以使用跟踪记录完成的操作,而使用 Logcat 很难做到。
另一方面,性能分析(我指的是自动性能分析)通常是为了应对您已知的问题。
例如,您可能注意到应用启动缓慢或出现卡顿,想要缩小问题范围并找出原因。
此时,如果您还没有手动跟踪记录来帮助您,自动性能分析功能就派上用场了。
您可以使用 ART 方法轨迹等工具,对每个方法的进入和退出进行插桩。
或者,您可能正在使用 Simpleperf 等工具进行堆栈采样。
因此,您无需使用每个方法入口和出口,也无需承担高昂的开销,只需查看调用堆栈样本,即可了解应用的热路径。
现在是 2026 年,RAM 仍然是一个普遍存在的问题,因此您可能需要关注内存、内存泄漏和堆分析报告。
因此,这是您可以利用的另一个性能分析器。
您可以查看堆保留路径。
您可以查看哪些代码在分配内存,这些都是您可以使用此工具进行的分析。
是的。
现在,我们已经掌握了这些词汇。
现在,我们来谈谈为什么要构建新库。
跟踪 API,1.0 API 最初是在 Android Jelly Bean 中引入的。
那是 2012 年的事。
那是 14 年前的事了。
自那时起,API 的发展并没有您想象的那么大。
我们添加了一些扩展程序。
Android 10 中添加了异步事件和计数器。
那是 2019 年的事。
这就是要做的所有事情了
自那以后,该 API 就没有再发生过变化。
不过,生态系统已经发生了很大变化。
因此,平台跟踪功能有了突飞猛进的改进,但这些改进并未以 API 增加的形式惠及您。
我们希望改变这种状况。
另一个大问题是,1.0 版中的跟踪具有固定的跟踪后端,这意味着,每当您在代码中添加跟踪部分时,它最终都会进入系统跟踪缓冲区。
Atrace 是拥有这些数据的 Android 跟踪子系统。
因此,如果您想获取跟踪记录并将其转换为字段遥测,这非常困难,因为现在,您必须了解 atrace 如何存储这些数据。
这是一种有据可查但可能难以使用的格式。
因此,这是我们需要解决的问题。
因此,我们希望为您提供工具,让您能够将跟踪数据导出到任何所需位置。
此外,在 Tracing 1.0 中,标签长度也有限制,不得超过 128 个字符。
在 2012 年,这可能是一个合理的限制,但在 2026 年,这似乎相当奇怪。
更糟糕的是,您也无法向跟踪记录添加任何额外的元数据,这会带来更多问题。
此外,还有开销问题。
因此,每当您跟踪应用时,都必须非常清楚开销,因为观察系统的行为不应改变系统在后台执行的操作。
因此,如果您想重现竞态条件,跟踪不应使竞态条件消失。
因此,您需要确保保留所有其他语义。
Tracing 1.0 的开销相对较高,我们希望对此进行改进。
好的。
我讲了很多问题。
现在,我们通过图示来了解一些问题,看看它们在实际应用中是如何叠加的。
这里有一份 Perfetto 跟踪记录。
这是针对应用“com.example.app”的。
我们有一个主线程,还有一个名为 initializeComponent 的切片。
现在,理想情况下,我们希望了解更多细节。
现在,我们来添加一个细节。
现在我们知道 initializeComponent 会调用 performInitialize。
我们仍然不知道正在初始化哪个组件。
现在,我们的工具带中唯一具有 Tracing 1 的工具是名称,因此我们将其添加到切片的名称中。
请注意,跟踪切片名称不得超过 128 个字符,因此在跟踪记录中会被截断。
现在,雪上加霜的是,截断开销现在也是您在跟踪时产生的开销。
现在,您很快就会遇到这个问题。
因此,我们非常希望解决这个问题。
您看到了 Perfetto 跟踪记录,也看到了它的直观呈现。
现在,我们从编写代码以发出这些跟踪记录部分的角度来看一下。
同样,我们有 initializeComponent API。
它接受上下文和名称,调用 performInitialize。
目前为止,一切都好。
现在,我们想使用 Tracing 1.0 API 添加跟踪记录,因此您需要导入 androidx.tracing.trace 并使用跟踪记录块。
您需要为其命名。
这里是 performInitialize。
但就像我们之前想做的那样,我们想知道组件的名称,因此我们将使用 Kotlin 的字符串插值,并添加名称。
第一个问题是:
因此,这里有 128 个字符数限制。
Rahul Ravikumar
我们可以使用 Tracing 2 解决此问题。
您可以使用 Tracer,而不使用 androidx.tracing.trace。
API 看起来非常相似。
您有 Tracer.global.trace,而不是 trace。
您有一个类别。
稍后我们将更详细地讨论类别,但从根本上说,您有一个名称。
然后,您仍然有要跟踪的代码块。
现在,您不再受 128 个字符数限制。
我谈到了跟踪开销。
我想更详细地介绍一下,看看我们是如何改进这一点的。
在搭载 Android 16 的 Pixel 6a 上,插入单个 begin 和 end 部分的操作大约需要 1,200 纳秒。
现在,这看起来可能不多,但如果您有非常热的代码,添加了大量跟踪部分,那么您的应用可能会花费更多时间来渲染帧。
因此,我们不想占用您的时间。
实际上,我们通过 AndroidX Perfetto SDK 进行了相当多的改进。
这是我们为 Composition Tracing 引入的一个实验性 SDK,它实际上大大改善了这种情况。
现在,我们真的不希望您发布此 SDK,因为这是带有 JNI 绑定的 Perfetto C SDK,而 C SDK 在 APK 中大约有 4 MB。
我们绝不希望您寄送这样的商品。
现在,有了 Tracing 2,我们就有了一个专门构建的库。
我们大幅减少了开销,现在 Tracing 2 的速度提高了 7 倍,这意味着您有更多时间做想做的事情,而基础架构所需的时间更少。
这样的技术能带来哪些以前无法实现的可能性?
例如,Metro 的运行时跟踪功能。
Metro 是一个依赖项注入库,可能有人还不知道。
它支持 Kotlin Multiplatform。
它使用 Kotlin 编译器插件。
它会生成用于依赖项注入的所有代码,并与 Tracing 2.0 集成,以便更轻松地直观呈现代码中每个 @Inject 的开销。
下面是一个示例。
假设我们有一个应用图。
在 Metro 中,所有内容都限定在图表中,因此我们只有一个字段 AnalyticsTracker。
我们还有一个 provides 绑定。
我们有一个网络客户端,其实现为 OkHttpClient,然后 AnalyticsTracker 的实现来自 AnalyticsTrackerImpl,我们需要一个网络客户端实例,图知道如何提供该实例。
因此,在后台,编译器插件会生成如下所示的代码。
因此,如果您有 AnalyticsTracker,getter 会创建网络客户端的实例,并将其传递给 AnalyticsTrackerImpl 的构造函数,这很好。
但现在,启用跟踪后,您不仅可以看到创建 tracker impl 的费用,还可以看到它所需的每个依赖项的费用。
现在,您不仅可以查看实例化类型的成本,还可以查看跟踪记录中的所有依赖项,这非常强大。
因此,金融应用 Wise 利用这些信息,将应用启动速度提升了 45%。
他们能够推迟在应用启动期间不必要初始化的内容,还发现了一些范围错误的项目,其中本应是单例的内容实际上并非单例,这些都是您可以在代码中看到并轻松修复的内容。
如果您查看他们的启动指标,会发现这些指标非常出色。
他们不仅缩短了初始帧显示时间,还缩短了完整显示时间。
另一家合作伙伴 BandLab 也在 CI 上使用相同的信息。
这样,您不仅可以知道每个 @Inject 绑定的费用,还可以知道是哪个线程产生了该费用。
因此,一旦您知道这一点,就可以在主线程和 CI 中找到所有高开销的 @Inject 绑定,您可以运行查询,例如“请提供所有高开销绑定的列表”,然后将其归因于引入这些绑定的更改,这样您就可以找到实际更改并尝试修复它。
Rahul Ravikumar
好的。
到目前为止,我介绍了我们针对 Tracing 1 所做的所有改进,现在我将介绍 Tracing 2 中以前从未实现过的新功能。
因此,其中一项核心功能称为“上下文传播”。由于现代应用变得越来越复杂,工作负载会在多个线程、多个协程和多个进程中运行。
您希望能够直观地了解请求在每个线程与进程之间的传播方式。
而这正是上下文传播所能实现的。
因此,如果我只给您一个切片列表,而不告诉您是什么导致了什么,这些切片虽然有用,但并不容易推理。
但如果我告诉您,这是发生的因果事件链,那么您就更容易弄清楚发生了什么,然后将每个切片的成本关联起来。
这就是上下文传播的作用。
您会很高兴地了解到,上下文传播功能已得到全面支持,下面是一个实际示例。
回顾 com.example.app Perfetto 跟踪记录,假设 performInitialize 耗时过长,您希望推迟该开销或将其移至其他线程。
您完全可以做到。
假设我将 performInitialize 移至其他线程。
现在,您已经失去了切片的层次结构。
因此,现在您无法再判断 initializeComponent 是导致 performInitialize 发生的原因,除非您查看代码。
在理想情况下,您希望有一个箭头来告诉您 A 导致了 B,然后您就可以推断出事件的因果链。
您甚至可能已经看到过系统跟踪,其中我们实际上针对平台上的 binder 事务执行了此操作。
因此,您可以在这里看到,Compose 基准会向 system_server 发起 binder 事务,以准备帧。
因此,从平台跟踪的角度来看,这些功能一直都是可行的,但我们从未向应用开发者提供过这些工具。
现在,Tracing 2.0 全面支持 Kotlin 协程上下文传播。
您无需采取任何措施。
开箱即用。
在这里,我们有一个名为 fetchProfile 的挂起方法。
它会调用 client.get(另一个挂起方法),然后切换到 Dispatchers.Default 以反序列化响应。
非常千篇一律的协程代码,您可能每天都会处理这些代码。
现在,假设我们要跟踪此请求。
我们添加了一个跟踪记录部分。
因此,这里我们不使用 trace,而是使用 traceCoroutine API,因为这是一个挂起代码块。然后,对于在上下文切换到 Dispatchers.Default 之前的内部调用,我们将使用相同的 traceCoroutine API 添加另一个内部 trace 部分。
现在,在跟踪记录中,您会看到如下内容。
您会看到,我们的工作从 worker-1 开始,然后实际切换了上下文并移至 worker-3,并且您会在跟踪记录中看到指向该位置的有用箭头。
不仅如此,如果您放大中间的方块,可以看到反序列化实际上是在 worker-1 中开始的,而对 withContext 的调用导致了上下文切换,然后,它继续在 worker-3 上运行,接着,fetchProfile 在 worker-3 上恢复,因为协程机制希望最大限度地减少发生的上下文切换次数。
您可以确切地看到协程何时挂起、何时恢复,还可以看到完整的事件因果链。
Tracing 2 还支持向跟踪记录添加自定义元数据。
过去,您只能使用跟踪记录部分的名称,但现在,您还可以添加元数据块,以便根据需要添加任意数量的元数据条目。
您可以将其添加到跟踪记录中。
查看实际跟踪记录时,您会发现,您添加的所有元数据实际上都包含在跟踪记录中,可供所有工具使用。
现在,您可以嵌入您所知道的所有信息,以及您一直想添加的所有其他信息。
我刚才介绍了一系列通过跟踪功能可以实现的抽象功能,但所有这些功能的亮点在于 Jetpack Compose 中的一项新功能,称为“重组跟踪”。
此功能已在 Compose 1.13 中发布,因此您现在就可以使用。
下面是一个示例。
这是大家可能都熟悉的组合跟踪。
自 Compose 早期版本起,此功能就已存在。
它会显示可组合项及其所有子切片的相对开销,以及每个子切片在发生时所在线程上的开销。
所以,这很简单。
但它无法提供背景信息。
它不会告诉您发生这种情况的原因。
它不会告诉您重组的原因。
因此,如果我缩小画面,这就是我们在上一张幻灯片中看到的感兴趣区域。
在左侧,您可以看到发生了一个分派输入事件。
这可能就是导致某些状态发生变化的原因,也可能就是导致重组的原因。
但目前尚不清楚。
你无法确定。
不过,借助重组跟踪,我们现在可以在跟踪记录中添加自定义元数据,以便准确告知您发生重组的原因。
不仅如此,现在您还可以轻松判断这些事件是否相关。
点击这些通知后,您会看到具体的状态变化。
它会告诉您,这是一个状态,是一个可变状态值,之前为 true,现在切换为 false。
不仅如此,我们甚至还会告诉您导致该问题的确切调用堆栈。
在这里,您可以看到,AbstractClickableNode.performClick 发生了,这导致可变状态发生变化,进而导致重组发生。
这真的很酷,我们以前无法做到这一点。
这些工具也可用,抱歉,所有这些功能也都在新的 Android Performance Analyzer 工具中提供,如果您想试用一些智能体工具的话。
右。
我之前提到过,Kotlin 协程完全支持上下文传播。
但如果代码混合使用了协程和非协程代码,或者您自行运行线程,或者您有一些因某种原因而使用回调的旧代码,该怎么办?
好的。
好消息是,您仍然可以获得上下文传播的所有好处。
假设我们有一个使用回调和执行器的棕地 API。
该实现使用 executor.execute,调用 runCatching(这是实现细节),获取响应,然后将响应告知回调。
好的。
现在,如果我想跟踪此操作,则需要执行 Tracer.global.trace,为其指定一个标签,然后将该代码块括起来。
但由于我还想保留事件的因果链,因此我会接受一种新的可选类型,称为传播令牌。
接下来,我只需将令牌传递给内部跟踪 API。
因此,无论谁调用此方法,都可以选择向我提供传播令牌,以便我保留发生的事件的因果链。
好的。
现在,该 API 的调用方可以是挂起 API。
这里有一个挂起函数 profileRequest,它现在将调用使用回调和执行器的 API。
在这里,由于我要连接两个世界,即挂起世界和回调世界,因此我将使用 suspendCoroutine 构建器。
我会获取一个延续,然后调用 API,并在收到响应后在回调中恢复延续。
然后,我定义的执行器名为 profileRequestContext。
它是一个单线程执行器。
由于 traceCoroutine 会在协程上下文中嵌入传播令牌,因此我可以从协程上下文中获取传播令牌,然后将其作为输入传递给棕地 API,这样我仍然可以保留事件的因果链。
在跟踪记录中,这一点非常明显。
因此,这里有一个在 worker-3 中启动的任务,在执行上下文切换后,它切换到了单线程执行器,然后又在 worker 上恢复执行。
所以这非常棒。
Rahul Ravikumar
好的。
您还可以自定义 Tracer.global。
到目前为止,Tracing 2.0 API 为您提供了非常好的 Tracer.global 默认值,但如果您想自定义,也可以。
此外,Tracing 2.0 还支持 JVM 和 Android,因为我们希望 Java 和 Android 库都能完全支持跟踪。
因此,初始化的三个步骤是:创建跟踪记录接收器。
跟踪记录接收器实际上是跟踪记录数据包的存储方式。
它定义了输出跟踪记录的传输格式和存储位置。
我们为您提供了一个支持 Perfetto 格式的跟踪记录接收器的开箱即用实现,但您显然可以编写自己的跟踪记录接收器。
这可以追溯到尝试解决 Tracing 1 中只有 atrace 后端可用的问题。
我们希望解决这个问题。
现在,如果您想编写 OpenTelemetry 接收器或自带接收器,完全可以做到。
您可以选择覆盖该设置。
有了接收器后,您就可以创建跟踪驱动程序,并为其提供接收器。
然后,您还可以选择要启用的类别。
因此,这正是类别大显身手的地方。
因此,您可以决定在发布版本中,某些类别比其他类别更重要(与调试版本相比),并指定要启用这些类别。
或者,您也可以直接说:“我想启用所有类别,无论它们来自何处。”
这完全没问题。
您还可以添加对跟踪记录而言是全局的自定义属性。
因此,您可以使用 addAttribute API 嵌入 build 版本、变体或您想要附加到跟踪记录的任何其他元数据。
最后,您可以通过调用 setGlobalTracer 来注册跟踪器。
这会将 Tracer.global 绑定到此跟踪器实例,随后,该实例可供进程使用。
在 Android 上,由于进程的生命周期定义得更清晰,您只需实现 AbstractTraceDriver.Factory,替换其 create 方法,然后执行与之前完全相同的操作即可。
因此,您需要先创建接收器,然后再创建驱动程序。
您无需在此处注册。
我们只会为您完成注册,因为我们知道这是最先发生的事件。
好的。
好了,我想重申一下,跟踪功能已支持多平台。
这是我们有意做出的选择,因为我们知道 Compose 运行在多个平台上,我们希望让您能够在尽可能多的地方进行跟踪。
目前,我们支持 Android 和 JVM,但我们的实现方式完全开放,因此我们可以在未来根据需要支持其他平台。
Rahul Ravikumar
您会很高兴地知道,R8(用于在 Android 上缩减和优化源代码的工具)使用的是 Tracing 2.0。
通过利用这些跟踪记录,我们优化了 R8,使代码缩减和优化速度提高了 25%。
这意味着构建时间更短,等待优化过程的时间也更短。
我不会逐一介绍我们在 R8 中所做的每项优化,而只想介绍其中一项,让您了解跟踪功能可以实现什么。
因此,R8 的核心是一个源代码到源代码的转换器。
因此,您会获得一堆 JAR,然后对其进行缩减和优化,并生成另一组可进行 dex 处理的 JAR。
因此,AGP 首先会读取 JAR,这不仅包括您的应用,还包括其所有依赖项。
因此,它会使用 Gradle 工作器开始读取所有输入 JAR。
您有许多 JAR 文件需要读取。
然后,在完成 JAR 的读取后,R8 会开始解析代码,并构建一个模型来表示代码及其所有入口点,以便开始优化和缩减代码。
现在,这个切片(await)表示 R8 真正准备好开始优化过程的时间,对吧?
您可能会看到中间有一些空白区域,并且可能会想知道为什么会有这些未使用的 CPU 时间。
这是因为旧版 API 的工作方式是,先等待所有工作器读取完所有文件,然后再解析内容。
显然,我们可以解决这个问题,而我们也确实做到了。
因此,在 AGP 9 中,您可以看到,我们一完成输入 JAR 的读取,就立即开始处理它们。
我们不必等待每个工作器都完成输入读取。
这实际上大大缩短了我们等待读取所有输入的时间。
因此,这是一个显而易见的优化,您在查看跟踪记录时会发现它非常明显,但如果您只是查看源代码来了解哪些内容值得优化,则不那么明显。
作为 AndroidX 工程师,我们每天都会使用的另一个工具是 Metalava。
Metalava 是我们用来确保我们发布的任何 API 都具有您期望的 API 兼容性保证,并且完全可供外部人员使用,而不会出现我们想要避免的任何问题。
该工具会在每个公共 API 和受限 API 上运行,因此,我们在这里节省的任何时间实际上都是 AndroidX 工程师节省的时间。
Metalava 还使用跟踪功能将提交后时间缩短了 15%,效果非常显著。
我们来深入分析一下跟踪记录,看看发生了什么。Metalava 会运行两种类型的检查:第一种是检查受限 API,第二种是检查公共 API。
但您可能已经注意到了一种模式。
我们正在进行大量设置,以便运行这些检查两次。
因此,实际情况是,系统会读取所有程序输入,然后构建 PSI(即程序结构接口),或者说,构建可供推理的代码的确切形状。
然后,我们构建 AST,再开始应用我们想要的所有 lint 检查。
而且我们还要执行两次这项开销很大的工作。
我们是如何解决这个问题的?
我们只做了一次。
因此,我们只需执行一次设置工作,而无需执行两次,并且将这些输入同时用于受限检查和公开检查,从而节省了所有这些费用。
没错
好的。
Rahul Ravikumar
现在,我已经让您初步了解了 Tracing 2.0 的功能,但请务必注意,虽然跟踪很重要,但知道要跟踪什么也同样重要。
您不希望到处都散布着跟踪记录。
您需要非常谨慎地选择要跟踪的内容,我将尝试为您提供一些关于哪些内容值得跟踪的准则。
因此,您首先应该做的是在跟踪记录中找出无法解释的间隙。
因此,只要您的线程处于运行或可运行状态,并且您在系统跟踪中看到明显的间隙,而您无法解释这些间隙,也不知道应用在做什么,那么这可能值得调查。
此时,我可能会获取一个与系统跟踪叠加的方法轨迹,看看哪些内容值得跟踪,然后添加这些相关的跟踪部分。
您还需要根据用户体验历程确定优先级。
因此,您需要查看应用启动情况。
您希望查看核心应用用户体验历程,并希望通过查看跟踪记录来了解是否可以解释这些历程。
因此,只要您无法对某项内容进行推理,就值得进行跟踪。
您还应查看跟踪记录中是否存在深度调用堆栈或无法解释的 CPU 使用情况,并尝试弄清楚具体情况,然后将代码库中的这些部分转换为跟踪记录。
因此,您可以跟踪它们。
然后,每当线程处于休眠状态时,您都需要确保自己能够了解其休眠的原因。
它们应该在等待 I/O,或者应该在等待 binder 事务,或者应该在等待您预期会发生的一些外部输入事件。
但如果你有不明原因的睡眠问题,那就非常值得调查,因为显然可能存在潜在问题。
然后,在代码中添加所有跟踪部分后,您应该将它们转换为基准,以便跟踪其中的变化。
因此,随着代码库的发展,您可以查看指标是否发生变化,信息中心是否确实反映了重大变化,因为这样您就知道发生了重要变化。
或者,您也可以跳过前面的所有步骤,直接让代理为您代劳。
将这些准则转化为一项技能,然后让您喜爱的智能体学习这项技能,并让它尝试执行。
没错
Rahul Ravikumar
如果您已经相信跟踪是一项值得投入的工作,并且您应该这样做,那么请按照以下步骤设置项目。
这非常简单。
Tracing 只是一个简单的传统 Java 库,因此您需要定义对 androidx.tracing:tracing-wire 的依赖项,它是与 Perfetto 兼容的跟踪驱动程序和跟踪组合,当前稳定版本为 2.0.5。
如果您使用的是 Java 库,情况也完全一样;同样,这是一个完全多平台的 API。
我们希望在合适的情况下将其扩展到其他平台。
我想快速回顾一下。
我刚才介绍了我们添加的所有这些新功能。
因此,上下文传播功能非常强大。
我们能够嵌入自定义信息、元数据、调用堆栈信息以及各种其他内容。
我还有很多很多事情没有谈到。
您显然可以更改跟踪格式,可以更改跟踪接收器,还可以将跟踪数据传输到 OpenTelemetry。
这些都是您可以采取的措施。
跟踪是跨平台的。
这同样是一个非常非常慎重的决定,因为我们希望尽可能多的人能够从中受益。
我们提供的所有工具,无论是 Perfetto、AndroidX Benchmarks、新的性能分析器,还是旧的 Android Profiler,都支持 Tracing 2.0。
最后,非常感谢大家的聆听。
祝您在开发者峰会上收获满满,谢谢!