1. 前言
本文是前作「Lambda 设计参考」的实战部分,具体将介绍如何使用 ASM 对 Java 8 Lambda 表达式和方法引用进行 Hook 操作。在此之前会介绍一些基础概念和字节码相关的知识方便大家对这块内容的理解,最后会给出一个完整的代码供大家参考。
2. 脱糖
2.1. 概念介绍
Java 脱糖(Desugar):简单地说,就是在编译阶段将语法层面一些底层字节码不支持的特性转换为底层支持的结构。例如:可以在 Android 中使用 Java 8 的 Lambda 特性,就是使用了脱糖。使用脱糖的最主要原因是 Android 设备并没有提供 Java 8 的运行时环境。下面用一个例子来展示对 Lambda 脱糖需要做的工作。
class Java8 {
interface Logger {
void log(String s);
}
public static void main(String... args) {
sayHi(s -> System.out.println(s));
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
}
首先是将 Lambda 方法体中的内容从 main 方法中移到 Java8 类的内部方法中,改变后的结果如下:
public class Java8 {
interface Logger {
void log(String s);
}
public static void main(String... args) {
//使用 lambda$main$0 替换原有的逻辑
sayHi(s -> lambda$main$0(s));
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
//方法体中的内容移到这里
static void lambda$main$0(String str){
System.out.println(str);
}
}
接着生成一个类,这个类实现了 Logger 接口,实现的方法中调用 lambda$main$0 方法,并且使用实现类替换代码 sayHi(s -> lambda$main$0(s)) ,改变后的代码如下:
public class Java8 {
interface Logger {
void log(String s);
}
public static void main(String... args) {
//这里使用 Logger 的实现类 Java8$1
sayHi(s -> new Java8$1());
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
//方法体中的内容移到这里
static void lambda$main$0(String str){
System.out.println(str);
}
}
public class Java8$1 implements Java8.Logger {
public Java8$1(){
}
@Override
public void log(String s) {
//这里调用 Java8 方法的静态方法
Java8.lambda$main$0(s);
}
}
最后,因为 Lambda 并没有捕获外部作用的任何变量,所以这是一个无状态 Lambda。实现类会生成一个单例,在使用的地方用这个单例来替换 new Java8$1(),最终的代码如下:
class Java8 {
interface Logger {
void log(String s);
}
public static void main(String... args) {
//此处使用单例替换原有代码
sayHi(Java8$1.INSTANCE);
}
static void lambda$main$0(String s) {
System.out.println(s);
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
}
public class Java8$1 implements Java8.Logger {
static final Java8$1 INSTANCE = new Java8$1();
@Override
public void log(String s) {
Java8.lambda$main$0(s);
}
}
这个例子简单地展示了脱糖的过程,其中 lambda$main$0 方法会在编译的时候生成。需要注意的是:方法引用并不会生成额外的方法(关于方法引用和 lambda$main$0 的生成规则以及上面提到的 无状态 lambdas 等知识可以通过「Lambda 设计参考」获取,读者如果对这部分内容不了解可以先看这篇文章)。
2.2. Android 中的脱糖
上一节介绍了什么是脱糖以及用一个简单的例子来演示 Lambda 表达式的脱糖逻辑,那么我们为什么要关注 Android 中的脱糖呢?
首先 Android 系统本身并不支持 Java 8,前面说了 Android 设备并没有提供 Java 8 的运行时环境。因此,App 项目使用 Java 8 编译产生的字节码是无法在 Android 设备上解析的,Android 使用 Gradle 在编译时会将 .class 文件中的一些 Java 8 语法特性脱糖成 Java 7 中支持的语法特性。我们看下图 2-1 描述的 Android 处理 Java 文件的流程,注意图中的 “Third-party plugins” 是 Android 为我们提供的可以在编译期有机会处理 .class 文件。关于插件开发,可以参考我司出版的《Android 全埋点解决方案》一书。
图 2-1 Android 处理 Java 文件的流程(来源:https://developer.android.com...)
根据图 2-1 所示,自定义的 Android 插件是在 D8/R8 之前先操作 .class 文件。D8 是 Android 提供的脱糖工具,这就导致自定义插件获取的 .class 是原始未脱糖的 .class(注:多个自定义插件执行顺序跟引入顺序有关,我们自定义的插件获取到的 .class 可能是其他插件处理过的 )。现在我们来分析下面这段代码:
View.setOnClickListener(System.out::println)
Android 开发者对这段代码很容易理解。现在我们对这段代码进行处理,希望在执行点击事件的时候,除了执行 println 方法,同时还能够加入一些其他的逻辑,如下面代码的描述:
View.setOnClickListener(view->{
System.out.println(view); // 方法引用
SensorsDataAutoTrackHelper.trackViewClick(view); //添加的额外逻辑
})
因为这里是一个方法引用,并不会像 Lambda 表达式那样在编译时生成一个 lambd$ 开头的方法(注:关于这块的描述请参考 「Lambda 设计参考」),而且我们也不能在 println 方法中插入代码,本文就是给大家介绍如何处理这种情况。
注意
Android 可以选择在工程中关闭 D8 的脱糖功能(可以通过在 gradle.properties 里配置 android.enableD8.desugaring=false 来关闭),那么 .class 文件的处理流程会变成:.class → desugar → third-party plugins → dex。
- invokedynamic 指令
在正式介绍如何使用 ASM 处理 Lambda 和方法引用之前,我们首先了解一下字节码指令 invokedynamic。invokedynamic 指令是在 JDK 7 引入的,用来实现动态类型语言功能,简单来说就是能够在运行时去调用实际的代码。在进一步介绍 invokedynamic 指令之前,我们先熟悉几个类:MethodType、MethodHandle、CallSite 。在介绍这几个类之前我们先来了解一个方法的构成:
方法名;
方法签名(参数类型和返回值类型);
方法所在的类;
方法体(方法中的代码)。
根据上面方法的构成,我们来依次介绍上面的几个类的用法。
3.1. MethodType
MethodType 代表一个方法所需的参数签名和返回值签名,MethodType 类有多个静态方法来构造 MethodType 对象,示例如下:
MethodType methodType = MethodType.methodType(String.class, int.class);
上面这个 MethodType 描述的是返回值为 String 类型,参数是一个 int 类型的方法签名,例如:int foo(String) 这个方法就符合这个描述。
3.2. MethodHandle
MethodHandle 翻译过来就是方法句柄,通过这个句柄可以调用相应的方法,MethodType 描述了方法的参数和返回值,MethodHandle 则是根据类名、方法名并且配合 MethodType 来找到特定方法然后执行它;MethodType 和 MethodHandle 配合起来完整表达了一个方法的构成。例如:我们调用 String.valueOf(int) 方法,可以这么做:
//声明参数和返回值类型
MethodType methodType = MethodType.methodType(String.class, int.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
//声明一个方法句柄:这里说明的是 String 类里面的 valueOf 方法,方法签名需要符合 methodType
MethodHandle methodHandle = lookup.findStatic(String.class, "valueOf", methodType);
//执行这个方法
String result = (String) methodHandle.invoke(99);
System.out.println(result);
这个跟反射很类似,从这个例子可以看出方法句柄里包含了需要执行的方法信息,只要传入所需的参数就可以执行这个方法了。
3.3. CallSite
CallSite 是方法调用点,调用点中包含了方法句柄信息,通常 invokedynamic 指令所描述的内容会使用 CallSite 来链接,关于这块内容的介绍也可以在 「Lambda 设计参考」找到。可以从调用点上获取 MethodHandle ,代码如下所示:
CallSite callSite = new ConstantCallSite(methodHandle);
MethodHandle mh = callSite.getTarget();
3.4. invokedynamic
前面介绍了一些跟 Lambda 相关的 API,下面正式介绍 invokedynamic,先看下面这段代码对应的字节码:
//源码部分
public class TestMain2 {
public void test() {
final Date date = new Date();
Consumer<String> consumer = (String s) -> System.out.println(s + date.toString());
}
}
//对应的部分字节码
Constant pool:
#4 = InvokeDynamic #0:#30 // #0(Ljava/util/Date;)Ljava/util/function/Consumer;
#5 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Class #33 // java/lang/StringBuilder
#7 = Methodref #6.#23 // java/lang/StringBuilder."<init>":()V
#8 = Methodref #6.#34 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#9 = Methodref #2.#35 // java/util/Date.toString:()Ljava/lang/String;
#10 = Methodref #6.#35 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#11 = Methodref #36.#37 // java/io/PrintStream.println:(Ljava/lang/String;)V
#12 = Class #38 // cn/curious/asm/method_ref/TestMain2
#19 = Utf8 lambda$test$0
#20 = Utf8 (Ljava/util/Date;Ljava/lang/String;)V
#21 = Utf8 SourceFile
#22 = Utf8 TestMain2.java
#23 = NameAndType #14:#15 // "<init>":()V
#24 = Utf8 java/util/Date
#25 = Utf8 BootstrapMethods
#26 = MethodHandle #6:#40 // invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#27 = MethodType #41 // (Ljava/lang/Object;)V
#28 = MethodHandle #6:#42 // invokestatic cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#29 = MethodType #43 // (Ljava/lang/String;)V
#30 = NameAndType #44:#45 // accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
#31 = Class #46 // java/lang/System
#32 = NameAndType #47:#48 // out:Ljava/io/PrintStream;
#33 = Utf8 java/lang/StringBuilder
#34 = NameAndType #49:#50 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#35 = NameAndType #51:#52 // toString:()Ljava/lang/String;
#36 = Class #53 // java/io/PrintStream
#37 = NameAndType #54:#43 // println:(Ljava/lang/String;)V
#38 = Utf8 cn/curious/asm/method_ref/TestMain2
#39 = Utf8 java/lang/Object
#40 = Methodref #55.#56 // java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#41 = Utf8 (Ljava/lang/Object;)V
#42 = Methodref #12.#57 // cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#43 = Utf8 (Ljava/lang/String;)V
#44 = Utf8 accept
#45 = Utf8 (Ljava/util/Date;)Ljava/util/function/Consumer;=
{
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/Date
3: dup
4: invokespecial #3 // Method java/util/Date."<init>":()V
7: astore_1
8: aload_1
9: invokedynamic #4, 0 // InvokeDynamic #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
14: astore_2
15: return
private static void lambda$test$0(java.util.Date, java.lang.String);
descriptor: (Ljava/util/Date;Ljava/lang/String;)V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=3, locals=2, args_size=2
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #6 // class java/lang/StringBuilder
6: dup
7: invokespecial #7 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: aload_0
15: invokevirtual #9 // Method java/util/Date.toString:()Ljava/lang/String;
18: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: return
}
InnerClasses:
public static final #61= #60 of #64; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#27 (Ljava/lang/Object;)V
#28 invokestatic cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#29 (Ljava/lang/String;)V
上面是部分主要的字节码信息,可以看下关键代码:
首先可以看第 58 行的 invokedynamic 指令:invokedynamic #4, 0 // invokeDynamic #0(Ljava/util/Date;)Ljava/util/function/Consumer,其中,0 是预留字段,#4 表示的是常量池中的字段;
第 11 行 #4 = InvokeDynamic #0:#30 // #0(Ljava/util/Date;)Ljava/util/function/Consumer,这里的 #0 表示的是第一个引导方法,假如有多个 Lambda 可能会有多个引导方法。所谓的引导方法指的是在执行 invokedynamic 指令时,该指令所指向的、需要去执行的 Java 方法,通常在执行引导方法的时候会生成一些额外的类,例如前面介绍脱糖的时候 Java8.Logger 的实现类 Java8$1,这个类会在第一次执行引导方法的时候生成,大家有兴趣可以看一下引导方法的源码;
第 83 行,这可以看到这个引导方法是 LambdaMetafacotry.metafactory,此方法的定义如下:
public static CallSite metafactory(MethodHandles.Lookup caller,
String invokedName,
MethodType invokedType,
MethodType samMethodType,
MethodHandle implMethod,
MethodType instantiatedMethodType)
这个方法会返回一个 Callsite 调用点,调用点中包括了方法句柄信息,我们现在来详细解释下这个方法的参数,其中前三个参数不需要关注,系统会自动生成,主要是看后面三个参数:
samMethodType: 函数式接口中抽象方法的签名描述信息,关于 MethodType 前面的章节有介绍,这里的方法签名是 Consumer#apply 的签名,因为泛型参数,泛型 T 统一被转换成 Object(注:这里的 sam 指的是 Single Abstract Method,大家可以理解为函数式接口);
implMethod: 是一个方法句柄,这个在前面也介绍了,方法句柄包含了具体需要执行的方法,从上面的字节码可以看到,这个方法句柄的内容是:#23 invokestatic cn/curious/asm/method_ref/TestMain2.lambda$main$0:(Ljava/lang/String;)V,意思是调用静态方法 lambda$main$0,在前面有介绍 Lambda 脱糖的时候我们知道,Lambda 会生成一个方法,此方法默认是隐藏的,如果想查看,可以使用 java 的 javap -p -v xxx.class 命令查看这个方法;
instantiatedMethodType: 是 samMethodType 的具体实现,源码传入的泛型类型是 String,所以这里就是 String。
接下来再看 invokedynamic 指令执行的前后代码:
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/Date
3: dup
4: invokespecial #3 // Method java/util/Date."<init>":()V
7: astore_1
8: aload_1
9: invokedynamic #4, 0 // InvokeDynamic #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
14: astore_2
15: return
从上面的指令可以看到,在执行 invokedynamic 指令的时候将创建的 Date 对象加载到栈顶,invokedynamic 指令对应的 accept:(Ljava/util/Date;)Ljava/util/function/Consumer; 中的 Date 就是动态参数,这个参数会添加在编译时生成的方法 lambda$test$0(java.util.Date, java.lang.String) 参数列表的前面。脱糖的具体规则在「Lambda 设计参考」中的 Lambda 方法体脱糖章节有详细的介绍。
4. 使用 ASM 实现
综合前三节的知识,我们知道方法句柄中包含了方法调用的信息,而且我们也说明了方法引用并不会生成一个 lambda$ 开头的中间方法,同时我们知道 MethodHandle 包含了方法调用的信息。因此,如果要去 Hook Lambda 和方法引用,我们可以创建一个新的 MethodHandle 替换原有的。具体做法是:我们会生成一个新的方法,新的方法中会实现 invokedynamic 指令中描述的代码逻辑。然后创建新的 MethodHandle,将这个 MethodHandle 替换原本的 MethodHandle。
现在整体的思路和方案已经有了,接下来就是使用 ASM 编写代码来实现,具体的实现如下:
public class MethodReferenceAdapter extends ClassNode {
private final AtomicInteger counter = new AtomicInteger(0);
private List<MethodNode> syntheticMethodList = new ArrayList<>();
public MethodReferenceAdapter(ClassVisitor classVisitor) {
super(Opcodes.ASM7);
this.cv = classVisitor;
}
@Override
public void visitEnd() {
super.visitEnd();
this.methods.forEach(methodNode -> {
ListIterator<AbstractInsnNode> iterator = methodNode.instructions.iterator();
while (iterator.hasNext()) {
AbstractInsnNode node = iterator.next();
if (node instanceof InvokeDynamicInsnNode) {
InvokeDynamicInsnNode tmpNode = (InvokeDynamicInsnNode) node;
//形如:(Ljava/util/Date;)Ljava/util/function/Consumer; 可以从 desc 中获取函数式接口,以及动态参数的内容。
//如果没有参数那么描述符的参数部分应该是空。
String desc = tmpNode.desc;
Type descType = Type.getType(desc);
Type samBaseType = descType.getReturnType();
//sam 接口名
String samBase = samBaseType.getDescriptor();
//sam 方法名
String samMethodName = tmpNode.name;
Object[] bsmArgs = tmpNode.bsmArgs;
//sam 方法描述符
Type samMethodType = (Type) bsmArgs[0];
//sam 实现方法实际参数描述符
Type implMethodType = (Type) bsmArgs[2];
//sam name + desc,可以用来辨别是否是需要 Hook 的 lambda 表达式
String bsmMethodNameAndDescriptor = samMethodName + samMethodType.getDescriptor();
//中间方法的名称
String middleMethodName = "lambda$" + samMethodName + "$sa" + counter.incrementAndGet();
//中间方法的描述符
String middleMethodDesc = "";
Type[] descArgTypes = descType.getArgumentTypes();
if (descArgTypes.length == 0) {
middleMethodDesc = implMethodType.getDescriptor();
} else {
middleMethodDesc = "(";
for (Type tmpType : descArgTypes) {
middleMethodDesc += tmpType.getDescriptor();
}
middleMethodDesc += implMethodType.getDescriptor().replace("(", "");
}
//INDY 原本的 handle,需要将此 handle 替换成新的 handle
Handle oldHandle = (Handle) bsmArgs[1];
Handle newHandle = new Handle(Opcodes.H_INVOKESTATIC, this.name, middleMethodName, middleMethodDesc, false);
InvokeDynamicInsnNode newDynamicNode = new InvokeDynamicInsnNode(tmpNode.name, tmpNode.desc, tmpNode.bsm, samMethodType, newHandle, implMethodType);
iterator.remove();
iterator.add(newDynamicNode);
generateMiddleMethod(oldHandle, middleMethodName, middleMethodDesc);
}
}
});
this.methods.addAll(syntheticMethodList);
accept(cv);
}
private void generateMiddleMethod(Handle oldHandle, String middleMethodName, String middleMethodDesc) {
//开始对生成的方法中插入或者调用相应的代码
MethodNode methodNode = new MethodNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC /*| Opcodes.ACC_SYNTHETIC*/,
middleMethodName, middleMethodDesc, null, null);
methodNode.visitCode();
// 此块 tag 具体可以参考: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokedynamic
int accResult = oldHandle.getTag();
switch (accResult) {
case Opcodes.H_INVOKEINTERFACE:
accResult = Opcodes.INVOKEINTERFACE;
break;
case Opcodes.H_INVOKESPECIAL:
//private, this, super 等会调用
accResult = Opcodes.INVOKESPECIAL;
break;
case Opcodes.H_NEWINVOKESPECIAL:
//constructors
accResult = Opcodes.INVOKESPECIAL;
methodNode.visitTypeInsn(Opcodes.NEW, oldHandle.getOwner());
methodNode.visitInsn(Opcodes.DUP);
break;
case Opcodes.H_INVOKESTATIC:
accResult = Opcodes.INVOKESTATIC;
break;
case Opcodes.H_INVOKEVIRTUAL:
accResult = Opcodes.INVOKEVIRTUAL;
break;
}
Type middleMethodType = Type.getType(middleMethodDesc);
Type[] argumentsType = middleMethodType.getArgumentTypes();
if (argumentsType.length > 0) {
int loadIndex = 0;
for (Type tmpType : argumentsType) {
int opcode = tmpType.getOpcode(Opcodes.ILOAD);
methodNode.visitVarInsn(opcode, loadIndex);
loadIndex += tmpType.getSize();
}
}
methodNode.visitMethodInsn(accResult, oldHandle.getOwner(), oldHandle.getName(), oldHandle.getDesc(), false);
Type returnType = middleMethodType.getReturnType();
int returnOpcodes = returnType.getOpcode(Opcodes.IRETURN);
methodNode.visitInsn(returnOpcodes);
methodNode.visitEnd();
syntheticMethodList.add(methodNode);
}
}
我们对前面介绍的示例中的 .class 文件使用 ASM 运行后输出的结果如下:
public class TestMain2 {
public TestMain2() {
}
public void test() {
Date var1 = new Date();
Consumer var2 = TestMain2::lambda$accept$sa1;
}
private static void lambda$accept$sa1(Date var0, String var1) {
//TODO 可以在此插桩
lambda$test$0(var0, var1);
}
}
其中,lambda$accept$sa1 是我们使用 ASM 生成的方法。在这个方法中,我们替换了原本的 lambda$test$0(此方法的 tag 是 acc_synthetic,表示代码是自动生成的,反编译默认不显示)方法,在我们生成的方法中调用编译器生成的 lambda$test$0 方法。这里需要再提醒一下,方法引用并不会生成类似 lambda$test$0 这样的方法,我们需要将方法引用的代码放在我们生成的方法中,这个读者可以写一个方法引用测试一下结果。
至此,如果我们想要对 Lambda 或者方法引用的代码进行插桩,只要在我们生成的方法中插入即可。
5. 总结
整体的原理是:我们自己生成一个中间方法,如果是 Lambda ,那么我们在方法中调用这个 Lambda 编译时生成的中间方法;如果是方法引用,就把方法引用里的内容放到我们生成的中间方法中,然后将自定义的 MethodHandle 指向生成的方法;最后替换掉 Bootstrap Method 中的 MethodHandle,达到偷梁换柱的效果。
不过,这种方式的弊端是会多生成一些中间方法。
至此,用了两篇文章介绍了 ASM Hook Lambda 和方法引用的知识,希望对大家有所帮助。
6. 参考资料
D8 & R8: https://developer.android.com...
Android's Java 8 Support: https://jakewharton.com/andro...
JVM invokeydynamic instruction: https://docs.oracle.com/javas...
https://www.jianshu.com/p/d74...
https://developer.android.com...
https://www.infoq.com/news/20...
文章来源:公众号神策技术社区
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。