卡顿监测的方方面面
The following article is from 小木箱成长营 Author 小木箱
本文作者
作者:小木箱
链接:
https://juejin.cn/post/7214635327407308859
本文由作者授权发布。
如果学完小木箱卡顿监测的方案篇,那么任何人做Android卡顿监测都可以拿到结果。
什么是卡顿?
什么是卡死?
如果界面线程被阻塞超过几秒钟时间,那么用户可能会看到ANR对话框,这就是我们俗称的卡死。
APP为什么滑动卡顿、不流畅? 什么情况下应用会卡?
绘制机制历史演进
混沌时代(3.0前): 软件绘制
洪荒时代(3.0~4.1): 硬件加速 + DisplayList
什么是硬件加速?
android:hardwareAccelerated="false"
什么是RenderNode?
什么是DisplayList?
为什么要用到DisplayList?而不是CPU直接操作?
mAttachInfo.mThreadedRenderer.draw(mView,mAttachInfo,this);
void draw(View view,AttachInfo attachInfo,DrawCallbacks callbacks) {
final Choreographer choreographer = attachInfo.mViewRootImpl.mChoreographer;
choreographer.mFrameInfo.markDrawStart();
// 更新到DisplayList里面
updateRootDisplayList(view,callbacks);
if (attachInfo.mPendingAnimatingRenderNodes != null) {
final int count = attachInfo.mPendingAnimatingRenderNodes.size();
for (int i = 0; i < count; i++) {
registerAnimatingRenderNode(
attachInfo.mPendingAnimatingRenderNodes.get(i));
}
attachInfo.mPendingAnimatingRenderNodes.clear();
attachInfo.mPendingAnimatingRenderNodes = null;
}
int syncResult = syncAndDrawFrame(choreographer.mFrameInfo);
if ((syncResult & SYNC_LOST_SURFACE_REWARD_IF_FOUND) != 0) {
setEnabled(false);
attachInfo.mViewRootImpl.mSurface.release();
attachInfo.mViewRootImpl.invalidate();
}
if ((syncResult & SYNC_REDRAW_REQUESTED) != 0) {
attachInfo.mViewRootImpl.invalidate();
}
}
DisplayList绘制流程是怎么样的?
那么硬件加速和软件绘制有什么区别呢?
switch (mLayerType) {
case LAYER_TYPE_HARDWARE:
updateDisplayListIfDirty();
if (attachInfo.mThreadedRenderer != null && mRenderNode.isValid()) {
attachInfo.mThreadedRenderer.buildLayer(mRenderNode);
}
break;
case LAYER_TYPE_SOFTWARE:
buildDrawingCache(true);
break;
}
上古时代(4.1~5.0): Vsync + 三缓冲区
什么是屏幕刷新频率?
什么是帧率?
如何监听界面是否存在绘制行为呢?
getWindow().getDecorView().getViewTreeObserver().addOnDrawListener()
什么是画面撕裂问题?
怎么解决画面撕裂问题?
什么是双缓冲?
Back Buffer和Frame Buffer的交换时机是什么时候呢?
如何理解Vsync+双缓存
Google在Android 4.1系统中,如何触发Vsync时机窗口呢?
Vsync + 双缓冲区为什么解决不了丢帧问题?
Vsync + 三缓冲区是如何解决丢帧问题的?
末法时代(5.0后): RenderThread
RenderThread阻塞
什么是RenderThread?
UIThread阻塞
Surfaceview刷新为什么用户界面没有卡顿?
后台进程 CPU 高负载
复杂View
requestLayout
了解这些原因之后,我们就可以根据业界的APM方案定制化我们企业内部的APM方案呢。
ArgusAPM
BlockCanary
QQ空间卡慢组件
Matrix
微信广研
方案一: 主线程Printer监测
public static void loop() {
...
for (;;) {
...
// 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);
}
...
}
}
缺陷一
问题挑战1: 堆栈采集不准
问题挑战2:非耗时任务函数采集
问题挑战3: 无响应机制
解决方案: 精准采集方案
缺陷二
问题挑战: 堆栈采集失效
public final class Looper {
private Printer mLogging;
/**
* Control logging of messages as they are processed by this Looper. If
* enabled,a log message will be written to <var>printer</var>
* at the beginning and ending of each message dispatch,,dentifying the
* target Handler and message contents.
*
* @param printer A Printer object that will receive log messages, ,
* null to disable message logging.
*/
public void setMessageLogging(@Nullable Printer printer) {
mLogging = printer;
}
}
解决方案: Top堆栈精简
解决方案: Printer覆盖检测
设置前检查
设置后检查
方案二: Choreographer帧率测量
Choreographer渲染流程
Choreographer测量流程
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if(frameTimeNanos - mLastFrameNanos > 100) {
...
}
mLastFrameNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
});
@Override
public void dispatchBegin(long beginNs, long cpuBeginMs, long token) {
super.dispatchBegin(beginNs, cpuBeginMs, token);
// 记录当前方法执行的sIndex,单链表
indexRecord = AppMethodBeat.getInstance().maskIndex("EvilMethodTracer#dispatchBegin");
}
@Override
public void dispatchEnd(long beginNs, long cpuBeginMs, long endNs, long cpuEndMs, long token, boolean isVsyncFrame) {
super.dispatchEnd(beginNs, cpuBeginMs, endNs, cpuEndMs, token, isVsyncFrame);
long start = config.isDevEnv() ? System.currentTimeMillis() : 0;
long dispatchCost = (endNs - beginNs) / Constants.TIME_MILLIS_TO_NANO;
try {
// 超出时间,解析上传
if (dispatchCost >= evilThresholdMs) {
long[] data = AppMethodBeat.getInstance().copyData(indexRecord);
long[] queueCosts = new long[3];
System.arraycopy(queueTypeCosts, 0, queueCosts, 0, 3);
String scene = AppActiveMatrixDelegate.INSTANCE.getVisibleScene();
MatrixHandlerThread.getDefaultHandler().post(new AnalyseTask(isForeground(), scene, data, queueCosts, cpuEndMs - cpuBeginMs, dispatchCost, endNs / Constants.TIME_MILLIS_TO_NANO));
}
} finally {
indexRecord.release();
if (config.isDevEnv()) {
String usage = Utils.calculateCpuUsage(cpuEndMs - cpuBeginMs, dispatchCost);
MatrixLog.v(TAG, "[dispatchEnd] token:%s cost:%sms cpu:%sms usage:%s innerCost:%s",
token, dispatchCost, cpuEndMs - cpuBeginMs, usage, System.currentTimeMillis() - start);
}
}
}
方案三: 字节码插桩
4. 为了方便及高效记录函数执行过程,我们为每个插桩的函数分配一个独立 ID,在插桩过程中,记录插桩的函数签名及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。
工具对比
使用指南
需要分析Native代码,选Simpleperf 需要分析系统调用,选Systrace 需要分析应用流程和耗时,选TraceView / 插桩之后的Systrace 需要分析其他应用,选Nanoscope 灰度环境分析Java代码,选Rhea
怎么用一个指标直观反映卡顿呢?
监测指标
流畅Smoothness计算
当Vsync信号到达时会会调用HAL层的HWComposer.vsync()函数,通知HWComposer引擎进行GPU渲染和显示,,然后发送Vsync信号给SurfaceFinger处理。 SurfaceFinger接收到Vsync信号后,调用SurfaceFlinger的addResyncSample函数用来处理 Vsync 信号,addResyncSample函数可以将App的渲染帧同步到显示器的刷新时间,以避免出现撕裂和卡顿等问题。 EventThread通过onVsyncEvent函数将Vsync信号分发给需要使用Vsync信号的App,实现更平滑和流畅的渲染效果。 当系统收到显示器的 Vsync 信号时,DisplayEventReceiver.onVsync() 函数会被调用,并将时间戳和Displayer物理属性传递给App。 App收到时间戳和Displayer物理属性后,FrameHandler可以帮助应用程序将渲染帧与 Vsync 信号同步,当 FrameHandler 接收到 Vsync 信号时,FrameHandler会调用 sendMessage() 方法,并将帧同步消息作为参数传递给该方法。
流畅度存在哪些痛点问题?
流畅度指标是怎样衡量的?
指标一: 流畅度评分 压缩数据 加权放大卡顿
指标二: XPM评分(denzelzhou):离散程度 帧绘制时长到标准绘制时长的距离(点到线的距离) 距离标准绘制时长越远就越卡顿
监测范围
慢函数监测
技术需求: 通过外部配置阈值记录Android慢函数,如果超过阈值,那么将慢函数方法名和耗时间信息记录在本地JSON文件
首先定义一个类 SlowFunctionClassVisitor,继承自 ClassVisitor,用于实现对类的字节码的修改。 在 SlowFunctionClassVisitor 中重写 visitMethod 方法,用于实现对方法的字节码的修改。在 visitMethod 方法中,先调用父类的 visitMethod 方法,然后使用 ASM 的 API 生成新的方法字节码,并将原来的方法字节码替换为新的方法字节码。 在生成新的方法字节码时,使用 Label 和 JumpInsnNode 等 ASM 的 API 插入代码,实现对方法的耗时进行判断。如果方法耗时超过阈值,则记录慢函数信息,并将其写入本地 JSON 文件中。 为了实现记录慢函数信息和将其写入本地 JSON 文件中,使用了 org.json.JSONObject 和 java.io.FileWriter 等相关的 API。
dependencies {
implementation 'org.ow2.asm:asm:9.2'
implementation 'org.ow2.asm:asm-util:9.2'
}
public class SlowFunctionClassVisitor extends ClassVisitor {
private String className;
public SlowFunctionClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM9, classVisitor);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
className = name;
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);
return new SlowFunctionMethodVisitor(Opcodes.ASM9, mv, access, name, descriptor, className);
}
private static class SlowFunctionMethodVisitor extends AdviceAdapter {
private final String methodName;
private final String className;
protected SlowFunctionMethodVisitor(int api, MethodVisitor mv, int access, String name, String descriptor, String className) {
super(api, mv, access, name, descriptor);
this.methodName = name;
this.className = className;
}
private static final String startTimeFieldName = "_start_time";
@Override
protected void onMethodEnter() {
//在方法进入时插入代码
visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
visitLdcInsn("enter " + methodName);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
visitVarInsn(Opcodes.ALOAD, 0);
visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
visitFieldInsn(Opcodes.PUTFIELD, className, startTimeFieldName, "J");
}
@Override
protected void onMethodExit(int opcode) {
//在方法退出时插入代码
visitVarInsn(Opcodes.ALOAD, 0);
visitFieldInsn(Opcodes.GETFIELD, className, startTimeFieldName, "J");
visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
visitInsn(Opcodes.LSUB);
visitVarInsn(Opcodes.LSTORE, 2);
Label l1 = new Label();
visitVarInsn(Opcodes.LLOAD, 2
visitLdcInsn(100L); // 100ms
visitInsn(Opcodes.LCMP);
visitJumpInsn(Opcodes.IFLE, l1);
// 超过阈值,记录慢函数信息
visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
visitLdcInsn("exit " + methodName + " cost " + Long.toString(2L) + " ms");
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
// 加载阈值
visitLdcInsn("slow_function_threshold");
visitMethodInsn(Opcodes.INVOKESTATIC, "android/content/res/Resources", "getSystem", "()Landroid/content/res/Resources;", false);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/Resources", "getAssets", "()Landroid/content/res/AssetManager;", false);
visitLdcInsn("config.json");
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "android/content/res/AssetManager", "open", "(Ljava/lang/String;)Ljava/io/InputStream;", false);
visitTypeInsn(Opcodes.NEW, "org/json/JSONObject");
visitInsn(Opcodes.DUP);
visitTypeInsn(Opcodes.NEW, "java/io/InputStreamReader");
visitInsn(Opcodes.DUP);
visitVarInsn(Opcodes.ALOAD, 4);
visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/InputStreamReader", "<init>", "(Ljava/io/InputStream;)V", false);
visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "(Ljava/io/Reader;)V", false);
visitLdcInsn("slow_function_threshold");
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "optInt", "(Ljava/lang/String;)I", false);
// 比较耗时和阈值
visitVarInsn(Opcodes.LLOAD, 2);
visitInsn(Opcodes.LCMP);
visitVarInsn(Opcodes.ILOAD, 5);
Label l2 = new Label();
visitJumpInsn(Opcodes.IF_ICMPLE, l2);
// 超过阈值,写入本地JSON文件
visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
visitLdcInsn("write to local json file: " + methodName + " cost " + Long.toString(2L) + " ms");
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
visitTypeInsn(Opcodes.NEW, "org/json/JSONObject");
visitInsn(Opcodes.DUP);
visitVarInsn(Opcodes.ALOAD, 0);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Class", "getName", "()Ljava/lang/String;", false);
visitLdcInsn(methodName);
visitVarInsn(Opcodes.LLOAD, 2);
visitMethodInsn(Opcodes.INVOKESPECIAL, "org/json/JSONObject", "<init>", "()V", false);
visitVarInsn(Opcodes.ASTORE, 6);
visitTypeInsn(Opcodes.NEW, "java/io/FileWriter");
visitInsn(Opcodes.DUP);
visitLdcInsn("slow_function.json");
visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/FileWriter", "<init>", "(Ljava/lang/String;)V", false);
visitVarInsn(Opcodes.ASTORE, 7);
// 将慢函数信息写入JSON文件
visitVarInsn(Opcodes.ALOAD, 7);
visitVarInsn(Opcodes.ALOAD, 6);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "org/json/JSONObject", "toString", "()Ljava/lang/String;", false);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "write", "(Ljava/lang/String;)V", false);
visitVarInsn(Opcodes.ALOAD, 7);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "flush", "()V", false);
visitVarInsn(Opcodes.ALOAD, 7);
visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/FileWriter", "close", "()V", false);
visitLabel(l2);
}
super.visitInsn(opcode);
}
}
流畅性监测
FPS监测
定义一个FPS监测类,该类中包含FPS计算的逻辑:
public class FPSMonitor {
private static final long ONE_SECOND = 1000000000L;
private long lastTime = System.nanoTime();
private int frameCount = 0;
private int fps = 0;
public void update() {
long currentTime = System.nanoTime();
frameCount++;
if (currentTime - lastTime >= ONE_SECOND) {
fps = frameCount;
frameCount = 0;
lastTime = currentTime;
}
}
public int getFps() {
return fps;
}
}
使用ASM字节码框架在编译期间对代码进行插桩,将FPS监测的逻辑插入到游戏或应用程序的主循环中:
public class GameLoop {
private FPSMonitor fpsMonitor = new FPSMonitor();
public void loop() {
while (true) {
long startTime = System.nanoTime();
// 游戏或应用程序的主逻辑// ...// 在主循环中插入FPS监测逻辑
fpsMonitor.update();
int fps = fpsMonitor.getFps();
System.out.println("FPS: " + fps);
// 控制FPS为60long elapsedTime = System.nanoTime() - startTime;
long sleepTime = (1000000000L / 60) - elapsedTime;
if (sleepTime > 0) {
try {
Thread.sleep(sleepTime / 1000000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
FPS监测逻辑被插入到了应用程序的主循环中,在每一帧结束时计算FPS,并将计算结果输出到控制台。我们使用插件可以将上述代码转换成字节码文件。转换代码如下:
public class FpsMonitorClassVisitor extends ClassVisitor {
public FpsMonitorClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if (name.equals("loop")) {
mv = new FpsMonitorMethodVisitor(mv);
}
return mv;
}
private static class FpsMonitorMethodVisitor extends MethodVisitor {
public FpsMonitorMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}
@Override
public void visitCode() {
super.visitCode();
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "update", "()V", false);
mv.visitVarInsn(Opcodes.ALOAD, 0);
mv.visitFieldInsn(Opcodes.GETFIELD, "GameLoop", "fpsMonitor", "LFPSMonitor;");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "FPSMonitor", "getFps", "()I", false);
mv.visitFieldInsn(Opcodes.PUTSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitInsn(Opcodes.SWAP);
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false);
}
}
}
Thread监测
1. 线程数量
2. 线程时间
上报时机
业务降级
注意事项
方案优化
getStackTrace
性能损耗 需要暂停主线程运行
StackSampler
public class StackSampler {
private static final int MAX_STACK_DEPTH = 32; // 最大堆栈深度
private static final int MAX_STACK_SAMPLES = 100; // 最大缓存采样数
private static final long SAMPLE_INTERVAL = 100L; // 采样间隔,单位:毫秒
private final ConcurrentLinkedQueue<String> stackSamples; // 堆栈采样缓存
private final AtomicBoolean profiling; // 采样标志位
public StackSampler() {
stackSamples = new ConcurrentLinkedQueue<>();
profiling = new AtomicBoolean(false);
}
// SafePoint 采样方法
private void sample() {
if (profiling.compareAndSet(false,true)) {
// 在 SafePoint 处异步采样堆栈信息
new Thread(() -> {
// 延迟一段时间,等待所有线程进入 SafePoint
try {
Thread.sleep(SAMPLE_INTERVAL);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 采样堆栈信息并添加到缓存中
String stackTrace = getStackTrace();
stackSamples.offer(stackTrace);
// 重置采样标志位
profiling.set(false);
}).start();
}
}
// 获取当前线程的堆栈信息
private String getStackTrace() {
StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
StringBuilder sb = new StringBuilder();
for (int i = 2; i < Math.min(stackTrace.length,,AX_STACK_DEPTH + 2); i++) {
sb.append(stackTrace[i].toString()).append('\n');
}
return sb.toString();
}
// 输出所有采样结果
public void dump() {
int count = 0;
String stackTrace;
while ((stackTrace = stackSamples.poll()) != null && count < MAX_STACK_SAMPLES) {
System.out.println(stackTrace);
count++;
}
}
public static void main(String[] args) throws InterruptedException {
StackSampler sampler = new StackSampler();
sampler.sample(); // 开始采样
Thread.sleep(MAX_STACK_SAMPLES * SAMPLE_INTERVAL); // 等待采样完成
sampler.dump(); // 输出采样结果
}
}
AsyncGetCallTrace
#include <signal.h>
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <execinfo.h>
#define SAMPLE_INTERVAL 100 // 采样间隔,单位:毫秒
#define MAX_STACK_DEPTH 32 // 最大堆栈深度
#define MAX_STACK_SAMPLES 100 // 最大缓存采样数
volatile sig_atomic_t profiling = 0; // 采样标志位
// SIGPROF 信号处理函数
void profiling_handler(int signum) {
profiling = 1; // 标记需要采样堆栈信息
}
// 开始采样
void start_profiling() {
struct sigaction sa;
struct itimerval timer;
// 注册 SIGPROF 信号处理函数
sa.sa_handler = profiling_handler;
sa.sa_flags = SA_RESTART;
sigemptyset(&sa.sa_mask);
sigaction(SIGPROF, &sa, NULL);
// 设置定时器
timer.it_value.tv_sec = SAMPLE_INTERVAL / 1000;
timer.it_value.tv_usec = (SAMPLE_INTERVAL % 1000) * 1000;
timer.it_interval = timer.it_value;
setitimer(ITIMER_PROF, &timer, NULL);
}
// 停止采样
void stop_profiling() {
struct itimerval timer;
// 关闭定时器
timer.it_value.tv_sec = 0;
timer.it_value.tv_usec = 0;
timer.it_interval = timer.it_value;
setitimer(ITIMER_PROF, &timer, NULL);
}
// 收集堆栈信息并输出到标准输出流
void dump_stack() {
void *stack[MAX_STACK_DEPTH];
int depth = backtrace(stack, MAX_STACK_DEPTH);
if (depth > 0) {
backtrace_symbols_fd(stack, depth, STDOUT_FILENO);
}
}
int main() {
start_profiling(); // 开始采样
int sample_count = 0;
while (sample_count < MAX_STACK_SAMPLES) {
if (profiling) { // 如果需要采样堆栈信息
profiling = 0; // 重置采样标志位
dump_stack(); // 收集堆栈信息
sample_count++; // 统计采样数
}
}
stop_profiling(); // 停止采样
return 0;
}
通过 SIGPROF 定时器和信号处理函数的方式,可以在 Native 层收集堆栈信息,避免了在 Java 层获取堆栈信息的开销和性能问题。不过需要注意的是,堆栈采样对应用程序的性能有一定影响,需要权衡好采样间隔和采样深度等参数。
https://github.com/Tencent/matrix/wiki/Matrix-Android-TraceCanary
http://gityuan.com/2017/02/25/choreographer/
https://www.jianshu.com/p/9e8f88eac490
最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
扫一扫 关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!