Objective-C基础之一(深入理解OC对象)

4,381 阅读18分钟

OC对象的本质

平常我们使用Objective-C语法来编写代码,但是它的底层其实都是C或C++代码。Objective-C其实是在C语言的基础上增加了面向对象的特性。我们可以通过以下命令将Objective-C代码转换成C++代码:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc OC文件 -o 输出目标cpp文件

如果OC文件需要链接其它的框架,可以使用-framework参数:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc OC文件 -o 输出目标cpp文件 -framework 框架名称

与此同时,还需要下载runtime的源码,通过objc源码地址下载最新版本的objc源码,以便于后续使用。

OC对象的底层实现

在开发过程中,最常用到的就是OC的对象。几乎所有的类对象都是NSObject的子类,但是抛开OC的限制,NSObject底层是如何实现的呢?上文说到,所有的OC代码最后都会转换成C代码,所以我们通过一个例子来认识NSObject的底层实现。

  • 首先,创建一个XLPerson对象
@interface XLPerson : NSObject

@end

@implementation XLPerson


@end
  • 进入XLPerson.m文件所在目录,使用如下指令,将XLPerson.m文件转换成XLPerson_cpp.cpp文件
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc XLPerson.m -o XLPerson_cpp.cpp
  • 在生成的XLPerson_cpp.cpp文件中搜索XLPerson,就能找到以下结构体定义
struct XLPerson_IMPL {
	struct NSObject_IMPL NSObject_IVARS;
};

在XLPerson_IMPL中包含一个结构体成员NSObject_IVARS,它是NSObject_IMPL类型,查看NSObject_IMPL的代码如下:

struct NSObject_IMPL {
	Class isa;
};

由此可以看出,OC中的对象其实就是通过结构体来实现的。在NSObject_IMPL包含了一个Class类型的成员isa。继续查看Class的定义:

/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

可以发现其实Class就是一个objc_class类型的结构体指针。在最新的objc4的源码中的objc-runtime-new.h文件中,可以找到最新的objc_class的定义如下:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }
}

objc_class继承自结构体objc_object,而结构体objc_object的具体定义如下,内部只有一个isa指针

/// Represents an instance of a class.
struct objc_object {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
};

由于继承关系,结构体objc_class自然也就继承了objc_object的isa指针,所以objc_class也可以转换成如下写法:

struct objc_class {
    Class isa;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }
}
  • isa是继承自objc_object的属性(具体作用后文会说明)
  • superclass表示当前类的父类
  • cache则代表方法缓存。
  • bits是class_data_bits_t类型的属性,用来存放类的具体信息。

查看class_data_bits_t的具体实现如下:

//此处只列出核心的代码
struct class_data_bits_t {
    ......
    class_rw_t* data() {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    ......
}

这时候发现了通过bits的内部函数data()可以拿到class_rw_t类型的数据,查看class_rw_t的源码如下:

struct class_rw_t {
    // Be warned that Symbolication knows the layout of this structure.
    uint32_t flags;
    uint32_t version;

    const class_ro_t *ro;        //只读的属性ro

    method_array_t methods;      //方法列表
    property_array_t properties; //属性列表
    protocol_array_t protocols;  //协议列表

    Class firstSubclass;
    Class nextSiblingClass;
}

在结构体class_rw_t中存放着

  • 方法列表methods
  • 属性列表properties
  • 协议列表protocols。
  • 一个class_ro_t类型的只读变量ro

继续查看class_ro_t的源码如下:

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;  //当前instance对象占用内存的大小
    const uint8_t * ivarLayout;
    const char * name;              //类名
    method_list_t * baseMethodList; //基础的方法列表
    protocol_list_t * baseProtocols;//基础协议列表
    const ivar_list_t * ivars;      //成员变量列表
    
    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;//基本属性列表
}

此处就不得不说class_rw_t和class_ro_t的区别了,class_ro_t中存放着类最原始的方法列表,属性列表等等,这些在编译期就已经生成了,而且它是只读的,在运行期无法修改。而class_rw_t不仅包含了编译器生成的方法列表、属性列表,还包含了运行时动态生成的方法和属性。它是可读可写的。至于class_rw_t和class_ro_t更深层次的区别,我会放在介绍runtime的时候详细说明。

OC对象的内存分配

allocWithZone

在iOS中一般使用如下[[NSObject alloc] init]创建对象,其中[NSObject alloc]就是为NSObject分配内存空间,下面,我们就从源码入手,来理解OC对象是如何分配内存的。

  • [NSObject alloc]其实是调用allocWithZone方法来分配内存空间,所以我们查看objc源码中的NSObject.mm文件。找到_objc_rootAllocWithZone函数:
id 
_objc_rootAllocWithZone(Class cls, malloc_zone_t *zone)
{
    id obj;

#if __OBJC2__
    // allocWithZone under __OBJC2__ ignores the zone parameter
    (void)zone;
    obj = class_createInstance(cls, 0);
#else
    if (!zone) {
        obj = class_createInstance(cls, 0);
    }
    else {
        obj = class_createInstanceFromZone(cls, 0, zone);
    }
#endif

    if (slowpath(!obj)) obj = callBadAllocHandler(cls);
    return obj;
}
  • 找到其中分配内存的方法class_createInstance(cls, 0);
id class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}
  • 找到_class_createInstanceFromZone(cls, extraBytes, nil)方法,由于方法较长,此处只展示核心代码:
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    ......
    
    size_t size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    
    ......
}

可以看出,其中真正用来分配内存的是C函数calloc,calloc函数传入了两个参数,第一个参数表示对象的个数,第二个参数size表示对象占据的内存字节数。因此size就表示当前对象所需要的内存大小。

  • 这里的size变量表示当前对象所占用的内存大小,可以查看cls->instanceSize(extraBytes)属性内部实现,如下:
    // May be unaligned depending on class's ivars.
    uint32_t unalignedInstanceSize() {
        assert(isRealized());
        return data()->ro->instanceSize;
    }
    
    // Class's ivar size rounded up to a pointer-size boundary.
    uint32_t alignedInstanceSize() {
        return word_align(unalignedInstanceSize());
    }

    size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        if (size < 16) size = 16;
        return size;
    }

其中cls->unalignedInstanceSize()表示未进行内存对齐的内存大小,cls->alignedInstanceSize()是对未对齐的内存进行内存对齐操作,得到最终所需的内存大小。

这里有个细节,就是执行对齐操作得到的内存大小如果小于16个字节,那么最后分配的内存大小为16个字节,也就是说,我们创建对象时,分配的内存最少是16个字节。

OC对象的内存分配

获取内存大小的方法?

在iOS中,我们可以通过三种方式来获取一个对象的内存大小。

sizeof

sizeof,它其实不是一个函数,而是一个运算符,它和宏定义类似,在编译期就将传入的类型转换成具体的占用内存的大小。例如int是4个字节,那么sizeof(int)在编译期就会直接被替换成4

注意:sizeof需要传入一个类型过去,它返回的是一个类型所占用的内存空间

class_getInstanceSize

class_getInstanceSize(Class _Nullable cls),传入一个Class类型的对象就能得到当前Class所占用的内存大小。例如,class_getInstanceSize([NSObject class]),最后返回的是8,也就说明NSObject对象在内存中占用8个字节,而且由于NSObject最后会转化成结构体NSObject_IMPL,而且内部只有一个isa指针,所以也就可以理解为isa指针占用8个字节的存储空间。

class_getInstanceSize函数内部其实就是调用alignedInstanceSize函数获取到对象所需要的真实内存大小。

size_t class_getInstanceSize(Class cls)
{
    if (!cls) return 0;
    return cls->alignedInstanceSize();
}

在调用calloc函数进行内存分配的时候,是将alignedInstanceSize的值当作参数赋值给calloc函数,因此calloc函数可以有如下写法:

id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    ......
    size_t size = class_getInstanceSize(cls);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    ......
}

由此可以看出,class_getInstanceSize(Class _Nullable cls)所返回的其实是对象实际所需要的内存大小。

malloc_size

malloc_size(const void *ptr)函数,传入const void *类型的参数,就可以获取到当前操作系统所分配的内存大小。例如:还是利用NSObject来进行测试,malloc_size((__bridge const void *)([[NSObject alloc] init])),将NSObject类型的实例对象作为参数,最后得到的值为16,和我们之前使用class_getInstanceSize([NSObject class])得到的8不相同。

这是因为在iOS中,在分配内存时,如果对象所需要的内存大小小于16个字节,那么就分配给这个对象16个字节的内存空间。也就是每个对象至少分配16个字节的内存空间

size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        if (size < 16) size = 16;
        return size;
    }

三种获取内存方法对比

  • 首先,创建对象XLPerson,添加3个属性,如下
@interface XLPerson : NSObject{
    int _height;
    int _age;
    long _num;
}
  • 在main函数中创建XLPerson实例对象,并且使用上面的三种方式分别获取XLPerson类的内存大小:
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#import <malloc/malloc.h>

@interface XLPerson : NSObject{
    @public
    int _height;
    int _age;
    long _num;
}

@end

@implementation XLPerson

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        XLPerson *p = [[XLPerson alloc] init];
        p->_height = 10;
        p->_age = 20;
        p->_num = 25;
        
        NSLog(@"sizeof --> %lu", sizeof(p));
        
        NSLog(@"class_getInstanceSize --> %lu", class_getInstanceSize([XLPerson class]));
        
        NSLog(@"malloc_size --> %lu", malloc_size((__bridge const void *)(p)));
    }
    return 0;
}
  • 运行程序,打印出结果如下:

可以看出此时sizeof(p)返回8个字节,class_getInstanceSize返回24个字节,malloc_size则返回32个字节。3个方法返回的内存大小都不一样,这是为什么呢?

sizeof(p)为什么只返回8个字节呢?

其实sizeof(p)返回8个字节,这个很好理解,因为sizeof传入的是p,而p在此处表示的是一个指向XLPerson实例对象的一个指针,在iOS中,指针类型所占用的内存大小为8个字节。因此sizeof(p)所返回的并不是XLPerson对象的内存大小。

要想使用sizeof获取到XLPerson对象的内存大小,就需要知道XLPerson最终会转换成什么类型。通过上文的学习,我们知道,XLPerson内部其实是一个结构体,通过xcrun指令将文件转换成.cpp文件

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp

分析main.cpp文件可以得出,XLPerson最终会转换成如下结构体类型

struct NSObject_IMPL {
    Class isa;
};

struct XLPerson_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    int _height;
    int _age;
    long _num;
};

此时调用sizeof(struct XLPerson_IMPL)就可以得出struct XLPerson_IMPL类型所占用的内存为24字节,其实也就是XLPerson所占用的内存是24个字节。

由此可看出运算符sizeof(struct XLPerson_IMPL)和函数class_getInstanceSize([XLPerson class]返回的是对象真正所需要的内存大小。

为何malloc_size返回的内存大小和对象实际所需内存不同?

在了解malloc_size函数之前,我们先来分析一下XLPerson内部结构体所需要的真实内存大小。

struct XLPerson_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    int _height;
    int _age;
    long _num;
};
  • 首先我们知道NSObject_IMPL内部其实只有一个isa指针,因此它所占用的内存是8个字节
  • int类型的变量_height占用4个字节
  • int类型的变量_age占用4个字节
  • long类型的变量_num占用8个字节

所以,单纯从结构体层面分析的话,我们可以看出XLPerson_IMPL结构体所需要的内存是24个字节,这和上文的sizeof(struct XLPerson_IMPL)以及函数class_getInstanceSize([XLPerson class]返回的内存大小一致。由此可见,XLPerson所需要的内存就是24个字节。

可是为什么malloc_size所返回的内存大小确是32个字节呢?这就要说到内存对齐操作。

结构体的内存对齐操作

首先,我们先将上文中提到的XLPerson属性进行修改,去掉其中的_age属性

@interface XLPerson : NSObject{
    @public
    int _height;
    long _num;
}
@end

然后重新运行项目,可以看到XLPerson实际占用的内存还是24个字节,而通过分析我们可以发现XLPerson只需要20个字节的内存空间。

这就是结构体内存对齐操作所导致的,也就是上文中所说的alignedInstanceSize函数的作用。那么什么是结构体的内存对齐操作?

结构体不像数组,结构体中可以存放不同类型的数据,它的大小也不是简单的各个数据成员大小之和,限于读取内存的要求,而是每个成员在内存中的存储都要按照一定偏移量来存储,根据类型的不同,每个成员都要按照一定的对齐数进行对齐存储,最后整个结构体的大小也要按照一定的对齐数进行对齐。

结构体的内存对齐规则如下:

  • 第一个成员的首地址为0
  • 每个成员的首地址是自身大小的整数倍
  • 结构体的内存总大小是其成员中所含最大类型的整数倍

这就是为何XLPerson的内存大小为24个字节的原因。

iOS系统的内存对齐操作

既然XLPerson的内存占用为24个字节,那么为什么系统会给它分配32个字节呢?其实在iOS系统中也存在内存对齐操作。

我们可以通过打印内存信息来查看是否分配了32个字节,依旧是使用上面的例子

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        XLPerson *p = [[XLPerson alloc] init];
        p->_height = 1;
        p->_num = 3;
    }
    return 0;
}
  • 断点状态下,使用po p获取到p的内存地址为0x1005b4fd0
(lldb) po p
<XLPerson: 0x1005b4fd0>
  • 打开Xcode下Debug->Debug Workflow->View Memory,输入刚刚获取到的内存地址,可以得到对象p的内存分配情况

其中前8个字节存储着isa指针,蓝色框中的四个字节存放着_height=1,而绿色框中的8个字节存放着_num=3,这里因为结构体内存对齐原则,所以_num=3的内存地址从第17个字节开始,整个红色框的32个字节,就是系统分配给XLPerson实例对象的内存空间,这也证明了malloc_size((__bridge const void *)(p))返回的确实是系统分配给p对象的内存空间。

OC对象的分类

OC对象主要分为3种

  • instance实例对象
  • class类对象
  • mata-class元类对象

instance对象

instance对象就是通过alloc操作创建出来的对象,每次调用alloc操作都会创建出不同的instance对象,它们拥有各自独立分配的内存空间。例如上文中使用的XLPerson的实例对象

XLPerson *p1 = [[XLPerson alloc] init];
XLPerson *p2 = [[XLPerson alloc] init];

其中p1、p2就是实例对象,在内存中可以同时拥有多个同一类对象的实例对象。它们各自拥有一块内存空间,用来存储独有的信息。实例对象内部存放的内容如下(以XLPerson的实例对象为例):

XLPerson *p1 = [[XLPerson alloc] init];
p->_height = 10;
p->_num = 25;
  • isa指针,指向它的类对象
  • _height = 10
  • _num = 25

因为通过[XLPerson alloc]就能创建一个实例对象,所以每个实例对象内部会存放着一个isa指针,指向它的类对象,还存放着定义好的其它的成员变量的具体值。

class类对象

类对象是将具有相似属性和方法的对象抽象出来,从而形成类对象。它可以定义一些相似的方法和属性,不同的实例对象去引用类对象的属性或者方法,能减少代码的重复率。

运行如下代码:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        XLPerson *p1 = [[XLPerson alloc] init];
        XLPerson *p2 = [[XLPerson alloc] init];
        
        Class c1 = [XLPerson class];
        Class c2 = [p1 class];
        Class c3 = [p2 class];
        Class c4 = object_getClass(p1);
        Class c5 = object_getClass(p2);
        
        NSLog(@"\n c1 -> %p,\n c2 -> %p,\n c3 -> %p,\n c4 -> %p,\n c5 -> %p", c1, c2, c3, c4, c5);
    }
    return 0;
}

可以得到结果为:

通过结果可以发现,所有的class对象的内存地址都是相同的,这也就说明在内存中只有一个class对象,不管是使用上面的哪种方法获取到的class对象都是同一个。

class对象内部其实就是一个object_class的结构体,具体的结构定义在上文已经介绍过,这里只列举出class对象存储的主要信息:

  • isa指针
  • superClass
  • 属性信息(properties),存放着属性的名称,属性的类型等等,这些信息在内存中只需要存放一份
  • 对象方法信息(methods)
  • 协议信息(protocols)
  • 成员变量信息等等(ivars)

mata-class元类对象

元类其实也是一个class类型的对象,它内部的结构和类对象一致,但是元类对象中只存放了如下信息:

  • isa指针
  • superClass
  • 类方法信息(class method)

元类和类一样,在内存中只会存在一个元类对象。可以通过runtime的方法获取元类对象

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        XLPerson *p1 = [[XLPerson alloc] init];
        XLPerson *p2 = [[XLPerson alloc] init];
        
        Class c1 = object_getClass(p1);
        Class c2 = object_getClass(p2);
        
        Class mataC1 = object_getClass(c1);
        Class mataC2 = object_getClass(c2);
        
        BOOL c1_isMataClass = class_isMetaClass(c1);
        BOOL c2_isMataClass = class_isMetaClass(c2);
        BOOL mataC1_isMataClass = class_isMetaClass(mataC1);
        BOOL mataC2_isMataClass = class_isMetaClass(mataC2);
        NSLog(@"\n c1_isMataClass:%d,\n c2_isMataClass:%d,\n mataC1_isMataClass:%d,\n mataC2_isMataClass:%d"
              ,c1_isMataClass, c2_isMataClass, mataC1_isMataClass, mataC2_isMataClass);
        NSLog(@"\n c1 -> %p,\n c2 -> %p,\n mataC1 -> %p,\n mataC2 -> %p",
              c1, c2, mataC1, mataC2);
    }
    return 0;
}

调用结果如下:

在上图中,c1和c2都是类对象,所以返回0,mataC1和mataC2都是元类对象,所以返回1。同时mataC1和mataC2的内存地址完全相同,这也说明了元类对象在内存中确实只存在一份。

instance对象、class对象和mata-class对象的关系

上文多次提到,在Class对象内部都会有一个isa指针,那么这个isa指针的作用是什么呢?其实isa指针是instance对象、class对象和mata-class对象之间的桥梁。

isa指针的作用

  • instance对象的isa指针指向class对象,而且上文也说到,在instance对象中只存储了isa指针和具体的属性的值,当我们调用instance对象的实例方法时,其实是通过isa指针找到它的类对象,在类对象的对象方法列表(methods)中查找到方法的实现,并调用。
  • class对象的isa指针指向mata-class对象,当调用类方法时,会通过类对象的isa指针找到它的元类对象mata-class,然后在mata-class的方法列表中找到对应类方法的实现并执行。

superClass指针的作用

superClass其实就是指向class对象或者mata-class对象的父类,下面我们以一个简单的例子来具体说明:

@interface XLPerson : NSObject

- (void)run;
+ (void)sleep;

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        XLPerson *p1 = [[XLPerson alloc] init];
        [p1 run];
        [XLPerson sleep];
    }
}

class对象中superClass指针的作用

XLPerson继承自NSObject,并且声明了一个类方法sleep()和一个对象方法run(),当p1调用对象方法run()时

  • 首先会通过实例对象p1内部的的isa指针找到它的类对象。在类对象的方法列表中查找run()方法。
  • 如果类对象的方法列表中没有此方法的实现,那么会通过类对象的superClass指针找到它的父类对象,在此处就是NSObject对象,并且在NSObject对象的方法列表中寻找run()方法的实现,然后调用。

mata-class对象中superClass指针的作用

还是以上面的例子来说明,当XLPerson调用类方法sleep()时

  • 首先通过XLPerson的isa指针找到它的元类对象,在元类对象的方法列表中寻找sleep()方法。
  • 如果在XLPerson的元类对象中没有找到sleep()方法,那么会通过XLPerson的元类对象的superClass指针找到XLPerson的父类对象的元类对象,此处就是NSObject的元类对象,在NSObject元类对象的方法列表中找到sleep()方法并执行。

isa、superClass总结

首先先看一张非常经典的描述instance对象、类对象以及元类对象之间关系的图片。途中虚线代表isa指针,实线代表superClass指针。

  • instance对象的isa指针指向它的class对象
  • class对象的isa指针指向它的mata-class对象
  • mata-class对象的isa指向基类对象mata-class
  • class对象的superClass指向它的父类的class对象,基类的class对象的superClass指向nil
  • mata-class的superClass指向父类对象的mata-class,基类对象的mata-class的superClass指向基类对象自身(此处是比较特殊的地方)
  • 实例方法查找路线
    • 首先会通过isa指针到class对象中找
    • 如果找不到,通过superClass到父类的class对象中找
    • 如果还找不到,再到基类的class对象中查找
  • 类方法的查找路线
    • 首先会通过类对象的isa指针到mata-class中查找
    • 如果mata-class中找不到,通过superClass到父类的元类对象中查找
    • 如果在父类的元类对象中找不到,就到基类对象的元类对象中查找
    • 如果基类的元类对象中找不到,那么会到基类对象中查找。

OC对象面试题

一个NSObject对象占用了多少内存?

系统给一个NSObject对象分配了16个字节的内存空间(通过malloc_size函数申请内存),但是NSObject对象内部只有一个isa指针,所以它实际使用到了8个字节的内存,而由于ios的内存对齐原则,系统最少分配16个字节的内存空间。

可以通过class_getInstanceSize函数来获取NSObject占用内存大小

对象的isa指向哪里?有什么作用?

  • 实例对象的isa指针指向class对象
  • class对象的isa指针指向mata-class对象
  • meta-class对象的isa指针指向基类的meta-class对象

OC的类的信息(方法、属性、成员变量等等)分别存放在哪?

  • OC中实例对象的方法、属性、成员变量、协议信息等等存放在class对象中
  • 类方法存放在mata-class中
  • 成员变量的具体值存放在实例对象中,因为成员变量的描述信息比如它的类型是int等等,在内存中只需存储一份,所以将属性描述信息存放在类对象中,但是成员变量的值每个实例变量都不相同,所以每个实例对象存放一份

结束语

以上内容纯属个人理解,如果有什么不对的地方欢迎留言指正。

一起学习,一起进步~~~