iOS - 自动释放池与@autoreleasepool
博客专区 > HeroHY 的博客 > 博客详情
iOS - 自动释放池与@autoreleasepool
HeroHY 发表于3个月前
iOS - 自动释放池与@autoreleasepool
  • 发表于 3个月前
  • 阅读 4
  • 收藏 0
  • 点赞 0
  • 评论 0

腾讯云 十分钟定制你的第一个小程序>>>   

一、官网关于自动释放池的说明截取 NSAutoreleasePool NSAutoreleasePool 类被用来支持自动引用计数内存管理系统。一个自动释放池存储的对象当自己被销毁的时会向其中的对象发送 release 消息。 Overview 1.png 在一个自动引用计数的环境中(并不是垃圾回收机制),一个包含了多个对象的 NSAutoreleasePool 对象能够接收 autorelease 消息并且当销毁它的时候会对每一个池子中的对象发送 release 消息。因此,发送 autorelease 而不是 release 消息延长了对象的生命周期直到 pool 被清空的时候(当对象被保留的时候会更久)。一个对象能够被放到同一个池子中许多次,在这种情况下每放一次都会收到一个 release 消息。 在引用计数的环境中,Cocoa 期望有一个自动释放池能够保持有效。如果一个池子没有用了,需要自动释放的对象没有被释放从而会造成内存泄漏。在这种情况下,你的程序将会报错。 Application Kit 在事件循环开始的时候在主线程创建了一个自动释放池,并且在结束的时候去清空它,从而释放所有进程事件中生成的自动释放的对象。如果使用了 Application Kit ,就没必要再去创建自己的自动释放池。然而,如果你的应用在事件循环中创建了很多临时的自动释放的对象,创建临时的自动释放池会将有助于削减你内存峰值的占用。 你创建了一个 NSAutoreleasePool 对象根据 alloc 和 init 消息并且用 drain 来清空它。因为你不能够保留一个自动释放池(或者自动释放它。), 清空一个池子最终会影响它的销毁。你应该在创建它的同一个上下文来进行销毁的工作。 每一个线程(包括主线程)包含一个它自己的自动释放池对象的堆栈。作为一个新的被创建的池子,它们被添加到堆栈的顶部。当池子被释放的时候,它们从栈中被移除。自动释放的对象被放在当前线程的自动释放池的顶部。当一个线程终止的时候,它自动清空与它关联的所有的自动释放池。 线程 如果你想要 Cocoa 在 ApplicationKit 的主线程之外调用,比如你创建了一个 Foundation 的 应用或者你创建了一个线程,你需要创建你自己的自动释放池。 如果应用或线程是长久保存的并且潜在的生成了很多自动释放的对象,这时应该定期的清空并且创建自动释放池(就像 Application Kit 在主线程中做的那样);否则,对象的积累会增加内存的占用。如果,独立的线程并没有使用 Cocoa 的调用,你没有必要去创建一个自动释放池。 注意 如果使用了 POSIX 线程 APIS 而不是 NSThread 对象来创建线程,你不能使用 Cocoa,包括 NSautoreleasePool,除非 Cocoa 是在多线程模式下,Cocoa 进入了多线程模式只有在首次创建 NSThread 对象的时候,为了在第二个 POSIX 线程中使用 Cocoa ,你的应用必须首先至少创建了一个独立的 NSThread 对象,这个对象可以立即退出。你可以通过 NSThread 类方法 isMultiTheraded 来测试 Cocoa 是否在多线程模式下。 垃圾回收 在垃圾回收的环境下,是不需要自动释放池的。你可能写了一个 framework ,它被设计用来在垃圾回收环境和引用计数环境下都可工作。在这种情况下,你可以使用自动释放池去提示回收器回收可能是合适的。在垃圾回收环境中,如果必要会发送一个 drain 消息到池子中去触发垃圾回收机制;然而,release,是一个空操作。在引用计数的环境中,drain 和 release 的效果是一样的。通常,你应该使用 drain 而不是 release。 二、什么时候是用 @autoreleasepool 写基于命令行的的程序时,就是没有UI框架,如 AppKit 等 Cocoa 框架时。 当我们的应用有需要创建大量的临时变量的时候,可以是用 @autoreleasepool 来减少内存峰值。 为什么? 自动释放池可以延长对象的声明周期,如果一个事件周期很长,比如有一个很长的循环逻辑,那么一个临时变量可能很长时间都不会被释放,一直在内存中保留,那么内存的峰值就会一直增加,但是其实这个临时变量是我们不再需要的。这个时候就通过创建新的自动释放池来缩短临时变量的生命周期来降低内存的峰值。 这是一个说明这个问题的很好的例子。 YYKit 中的使用 for (int i = 0; i < count; i++) { @autoreleasepool { id imageSrc = _images[i]; NSDictionary *frameProperty = NULL; if (_type == YYImageTypeGIF && count > 1) { frameProperty = @{(NSString *)kCGImagePropertyGIFDictionary : @{(NSString *) kCGImagePropertyGIFDelayTime:_durations[i]}}; } else { frameProperty = @{(id)kCGImageDestinationLossyCompressionQuality : @(_quality)}; } if ([imageSrc isKindOfClass:[UIImage class]]) { UIImage *image = imageSrc; if (image.imageOrientation != UIImageOrientationUp && image.CGImage) { CGBitmapInfo info = CGImageGetBitmapInfo(image.CGImage) | CGImageGetAlphaInfo(image.CGImage); CGImageRef rotated = YYCGImageCreateCopyWithOrientation(image.CGImage, image.imageOrientation, info); if (rotated) { image = [UIImage imageWithCGImage:rotated]; CFRelease(rotated); } } if (image.CGImage) CGImageDestinationAddImage(destination, ((UIImage *)imageSrc).CGImage, (CFDictionaryRef)frameProperty); } else if ([imageSrc isKindOfClass:[NSURL class]]) { CGImageSourceRef source = CGImageSourceCreateWithURL((CFURLRef)imageSrc, NULL); if (source) { CGImageDestinationAddImageFromSource(destination, source, i, (CFDictionaryRef)frameProperty); CFRelease(source); } } else if ([imageSrc isKindOfClass:[NSData class]]) { CGImageSourceRef source = CGImageSourceCreateWithData((CFDataRef)imageSrc, NULL); if (source) { CGImageDestinationAddImageFromSource(destination, source, i, (CFDictionaryRef)frameProperty); CFRelease(source); } } } } 三、release 和 drain的区别 当我们向自动释放池 pool 发送 release 消息,将会向池中临时对象发送一条 release 消息,并且自身也会被销毁。 向它发送drain消息时,将会向池中临时对象发送一条release消息。 官方解释 release: 释放并且出栈接收者。(ARC) drain: 在引用计数环境中,会释放并且出栈接受者。 在垃圾回收环境中,会触发垃圾回收机制如果上次分配的内存集合大于当前的阈值。 四、Autoreleasepool 底层实现 首先我们去可以查看一下,clang 转成 c++ 的 autoreleasepool 的源码: extern "C" __declspec(dllimport) void * objc_autoreleasePoolPush(void); extern "C" __declspec(dllimport) void objc_autoreleasePoolPop(void *); struct __AtAutoreleasePool { __AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();} ~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);} void * atautoreleasepoolobj; }; 可以发现objc_autoreleasePoolPush() 和 objc_autoreleasePoolPop() 这两个方法。 再看一下runtime 中 Autoreleasepool 的结构,通过阅读源码可以看出 Autoreleasepool 是一个由 AutoreleasepoolPage 双向链表的结构,其中 child 指向它的子 page,parent 指向它的父 page。 双向链表结构图 并且每个 AutoreleasepoolPage 对象的大小都是 4096 个字节。 #define PAGE_MAX_SIZE PAGE_SIZE #define PAGE_SIZE I386_PGBYTES #define I386_PGBYTES 4096 /* bytes per 80386 page */ AutoreleasepoolPage 通过压栈的方式来存储每个需要自动释放的对象。 //入栈方法 static inline void *push() { id *dest; if (DebugPoolAllocation) { // Each autorelease pool starts on a new pool page. //在 Debug 情况下每一个自动释放池 都以一个新的 poolPage 开始 dest = autoreleaseNewPage(POOL_BOUNDARY); } else { //正常情况下,调用 push 方法会先插入一个 POOL_BOUNDARY 标志位 dest = autoreleaseFast(POOL_BOUNDARY); } assert(dest == EMPTY_POOL_PLACEHOLDER || *dest == POOL_BOUNDARY); return dest; } 然后我们来看看 runtime 中的源码 /*********************************************************************** 自动释放池的实现: 一个线程的自动释放池是一个指针堆栈 每一个指针或者指向被释放的对象,或者是自动释放池的 POOL_BOUNDARY,POOL_BOUNDARY 是自动释放池的边界。 一个池子的 token 是指向池子 POOL_BOUNDARY 的指针。当池子被出栈的时候,每一个高于标准的对象都会被释放掉。 堆栈被分成一个页面的双向链表。页面按照需要添加或者删除。 本地线程存放着指向当前页的指针,在这里存放着新创建的自动释放的对象。 **********************************************************************/ // Set this to 1 to mprotect() autorelease pool contents //将这个设为 1 可以通过mprotect修改映射存储区的权限来更改自动释放池的内容 #define PROTECT_AUTORELEASEPOOL 0 #define CHECK_AUTORELEASEPOOL (DEBUG) BREAKPOINT_FUNCTION(void objc_autoreleaseNoPool(id obj)); BREAKPOINT_FUNCTION(void objc_autoreleasePoolInvalid(const void *token)); namespace { //对AutoreleasePoolPage进行完整性校验 struct magic_t { static const uint32_t M0 = 0xA1A1A1A1; # define M1 "AUTORELEASE!" static const size_t M1_len = 12; uint32_t m[4]; magic_t() { assert(M1_len == strlen(M1)); assert(M1_len == 3 * sizeof(m[1])); m[0] = M0; strncpy((char *)&m[1], M1, M1_len); } ~magic_t() { m[0] = m[1] = m[2] = m[3] = 0; } bool check() const { return (m[0] == M0 && 0 == strncmp((char *)&m[1], M1, M1_len)); } bool fastcheck() const { #if CHECK_AUTORELEASEPOOL return check(); #else return (m[0] == M0); #endif } # undef M1 }; //自动释放页 class AutoreleasePoolPage { //EMPTY_POOL_PLACEHOLDER 被存放在本地线程存储中当一个池入栈并且没有存放任何对象的时候。这样在栈顶入栈出栈并且没有使用它们的时候会节省内存。 # define EMPTY_POOL_PLACEHOLDER ((id*)1) # define POOL_BOUNDARY nil static pthread_key_t const key = AUTORELEASE_POOL_KEY; static uint8_t const SCRIBBLE = 0xA3; // 0xA3A3A3A3 after releasing static size_t const SIZE = #if PROTECT_AUTORELEASEPOOL PAGE_MAX_SIZE; // must be multiple of vm page size #else PAGE_MAX_SIZE; // size and alignment, power of 2 #endif //对象数量 static size_t const COUNT = SIZE / sizeof(id); //校验完整性 magic_t const magic; //页中对象的下一位索引 id *next; //线程 pthread_t const thread; //父页 AutoreleasePoolPage * const parent; //子页 AutoreleasePoolPage *child; //深度 uint32_t const depth; uint32_t hiwat; // SIZE-sizeof(*this) bytes of contents follow //创建 static void * operator new(size_t size) { return malloc_zone_memalign(malloc_default_zone(), SIZE, SIZE); } //删除 static void operator delete(void * p) { return free(p); } //设置当前内存可读 inline void protect() { #if PROTECT_AUTORELEASEPOOL mprotect(this, SIZE, PROT_READ); check(); #endif } //设置当前内存可读可写 inline void unprotect() { #if PROTECT_AUTORELEASEPOOL check(); mprotect(this, SIZE, PROT_READ | PROT_WRITE); #endif } //初始化 AutoreleasePoolPage(AutoreleasePoolPage *newParent) : magic(), next(begin()), thread(pthread_self()), parent(newParent), child(nil), depth(parent ? 1+parent->depth : 0), hiwat(parent ? parent->hiwat : 0) { if (parent) { parent->check(); assert(!parent->child); parent->unprotect(); parent->child = this; parent->protect(); } protect(); } //析构 ~AutoreleasePoolPage() { check(); unprotect(); assert(empty()); // Not recursive: we don't want to blow out the stack // if a thread accumulates a stupendous amount of garbage assert(!child); } //被破坏的 void busted(bool die = true) { magic_t right; (die ? _objc_fatal : _objc_inform) ("autorelease pool page %p corrupted\n" " magic 0x%08x 0x%08x 0x%08x 0x%08x\n" " should be 0x%08x 0x%08x 0x%08x 0x%08x\n" " pthread %p\n" " should be %p\n", this, magic.m[0], magic.m[1], magic.m[2], magic.m[3], right.m[0], right.m[1], right.m[2], right.m[3], this->thread, pthread_self()); } //校验 void check(bool die = true) { if (!magic.check() || !pthread_equal(thread, pthread_self())) { busted(die); } } //快速校验 void fastcheck(bool die = true) { #if CHECK_AUTORELEASEPOOL check(die); #else if (! magic.fastcheck()) { busted(die); } #endif } //页的开始位置 id * begin() { return (id *) ((uint8_t *)this+sizeof(*this)); } //页的结束位置 id * end() { return (id *) ((uint8_t *)this+SIZE); } //页是否是空的 bool empty() { return next == begin(); } //页是否是满的 bool full() { return next == end(); } //是否少于一半 bool lessThanHalfFull() { return (next - begin() < (end() - begin()) / 2); } //添加对象 id *add(id obj) { assert(!full()); unprotect(); id *ret = next; // faster than `return next-1` because of aliasing *next++ = obj; protect(); return ret; } //释放所有对象 void releaseAll() { releaseUntil(begin()); } //释放到 stop 的位置之前的所有对象 void releaseUntil(id *stop) { // Not recursive: we don't want to blow out the stack // if a thread accumulates a stupendous amount of garbage while (this->next != stop) { // Restart from hotPage() every time, in case -release // autoreleased more objects AutoreleasePoolPage *page = hotPage(); // fixme I think this `while` can be `if`, but I can't prove it while (page->empty()) { page = page->parent; setHotPage(page); } page->unprotect(); id obj = *--page->next; //将页索引内容置为 SCRIBBLE 表示已经被释放 memset((void*)page->next, SCRIBBLE, sizeof(*page->next)); page->protect(); if (obj != POOL_BOUNDARY) { objc_release(obj); } } setHotPage(this); #if DEBUG // we expect any children to be completely empty for (AutoreleasePoolPage *page = child; page; page = page->child) { assert(page->empty()); } #endif } //杀死 void kill() { // Not recursive: we don't want to blow out the stack // if a thread accumulates a stupendous amount of garbage AutoreleasePoolPage *page = this; while (page->child) page = page->child; AutoreleasePoolPage *deathptr; do { deathptr = page; page = page->parent; if (page) { page->unprotect(); page->child = nil; page->protect(); } delete deathptr; } while (deathptr != this); } //释放本地线程存储空间 static void tls_dealloc(void *p) { if (p == (void*)EMPTY_POOL_PLACEHOLDER) { // No objects or pool pages to clean up here. return; } // reinstate TLS value while we work setHotPage((AutoreleasePoolPage *)p); if (AutoreleasePoolPage *page = coldPage()) { if (!page->empty()) pop(page->begin()); // pop all of the pools if (DebugMissingPools || DebugPoolAllocation) { // pop() killed the pages already } else { page->kill(); // free all of the pages } } // clear TLS value so TLS destruction doesn't loop setHotPage(nil); } //获取 AutoreleasePoolPage static AutoreleasePoolPage *pageForPointer(const void *p) { return pageForPointer((uintptr_t)p); } static AutoreleasePoolPage *pageForPointer(uintptr_t p) { AutoreleasePoolPage *result; uintptr_t offset = p % SIZE; assert(offset >= sizeof(AutoreleasePoolPage)); result = (AutoreleasePoolPage *)(p - offset); result->fastcheck(); return result; } //是否有空池占位符 static inline bool haveEmptyPoolPlaceholder() { id *tls = (id *)tls_get_direct(key); return (tls == EMPTY_POOL_PLACEHOLDER); } //设置空池占位符 static inline id* setEmptyPoolPlaceholder() { assert(tls_get_direct(key) == nil); tls_set_direct(key, (void *)EMPTY_POOL_PLACEHOLDER); return EMPTY_POOL_PLACEHOLDER; } //获取当前页 static inline AutoreleasePoolPage *hotPage() { AutoreleasePoolPage *result = (AutoreleasePoolPage *) tls_get_direct(key); if ((id *)result == EMPTY_POOL_PLACEHOLDER) return nil; if (result) result->fastcheck(); return result; } //设置当前页 static inline void setHotPage(AutoreleasePoolPage *page) { if (page) page->fastcheck(); tls_set_direct(key, (void *)page); } //获取 coldPage static inline AutoreleasePoolPage *coldPage() { AutoreleasePoolPage *result = hotPage(); if (result) { while (result->parent) { result = result->parent; result->fastcheck(); } } return result; } //快速释放 static inline id *autoreleaseFast(id obj) { AutoreleasePoolPage *page = hotPage(); if (page && !page->full()) { return page->add(obj); } else if (page) { return autoreleaseFullPage(obj, page); } else { return autoreleaseNoPage(obj); } } //添加自动释放对象,当页满的时候调用这个方法 static __attribute__((noinline)) id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page) { // The hot page is full. // Step to the next non-full page, adding a new page if necessary. // Then add the object to that page. assert(page == hotPage()); assert(page->full() || DebugPoolAllocation); do { if (page->child) page = page->child; else page = new AutoreleasePoolPage(page); } while (page->full()); setHotPage(page); return page->add(obj); } //添加自动释放对象,当没页的时候使用这个方法 static __attribute__((noinline)) id *autoreleaseNoPage(id obj) { // "No page" could mean no pool has been pushed // or an empty placeholder pool has been pushed and has no contents yet assert(!hotPage()); bool pushExtraBoundary = false; if (haveEmptyPoolPlaceholder()) { // We are pushing a second pool over the empty placeholder pool // or pushing the first object into the empty placeholder pool. // Before doing that, push a pool boundary on behalf of the pool // that is currently represented by the empty placeholder. pushExtraBoundary = true; } else if (obj != POOL_BOUNDARY && DebugMissingPools) { // We are pushing an object with no pool in place, // and no-pool debugging was requested by environment. _objc_inform("MISSING POOLS: (%p) Object %p of class %s " "autoreleased with no pool in place - " "just leaking - break on " "objc_autoreleaseNoPool() to debug", pthread_self(), (void*)obj, object_getClassName(obj)); objc_autoreleaseNoPool(obj); return nil; } else if (obj == POOL_BOUNDARY && !DebugPoolAllocation) { // We are pushing a pool with no pool in place, // and alloc-per-pool debugging was not requested. // Install and return the empty pool placeholder. return setEmptyPoolPlaceholder(); } // We are pushing an object or a non-placeholder'd pool. // Install the first page. AutoreleasePoolPage *page = new AutoreleasePoolPage(nil); setHotPage(page); // Push a boundary on behalf of the previously-placeholder'd pool. if (pushExtraBoundary) { page->add(POOL_BOUNDARY); } // Push the requested object or pool. return page->add(obj); } static __attribute__((noinline)) id *autoreleaseNewPage(id obj) { AutoreleasePoolPage *page = hotPage(); if (page) return autoreleaseFullPage(obj, page); else return autoreleaseNoPage(obj); } //公开方法 public: //自动释放 static inline id autorelease(id obj) { assert(obj); assert(!obj->isTaggedPointer()); id *dest __unused = autoreleaseFast(obj); assert(!dest || dest == EMPTY_POOL_PLACEHOLDER || *dest == obj); return obj; } //入栈 static inline void *push() { id *dest; if (DebugPoolAllocation) { // Each autorelease pool starts on a new pool page. dest = autoreleaseNewPage(POOL_BOUNDARY); } else { dest = autoreleaseFast(POOL_BOUNDARY); } assert(dest == EMPTY_POOL_PLACEHOLDER || *dest == POOL_BOUNDARY); return dest; } //兼容老的 SDK 出栈方法 static void badPop(void *token) { // Error. For bincompat purposes this is not // fatal in executables built with old SDKs. if (DebugPoolAllocation || sdkIsAtLeast(10_12, 10_0, 10_0, 3_0)) { // OBJC_DEBUG_POOL_ALLOCATION or new SDK. Bad pop is fatal. _objc_fatal ("Invalid or prematurely-freed autorelease pool %p.", token); } // Old SDK. Bad pop is warned once. static bool complained = false; if (!complained) { complained = true; _objc_inform_now_and_on_crash ("Invalid or prematurely-freed autorelease pool %p. " "Set a breakpoint on objc_autoreleasePoolInvalid to debug. " "Proceeding anyway because the app is old " "(SDK version " SDK_FORMAT "). Memory errors are likely.", token, FORMAT_SDK(sdkVersion())); } objc_autoreleasePoolInvalid(token); } //出栈 static inline void pop(void *token) { AutoreleasePoolPage *page; id *stop; if (token == (void*)EMPTY_POOL_PLACEHOLDER) { // Popping the top-level placeholder pool. if (hotPage()) { // Pool was used. Pop its contents normally. // Pool pages remain allocated for re-use as usual. pop(coldPage()->begin()); } else { // Pool was never used. Clear the placeholder. setHotPage(nil); } return; } page = pageForPointer(token); stop = (id *)token; if (*stop != POOL_BOUNDARY) { if (stop == page->begin() && !page->parent) { // Start of coldest page may correctly not be POOL_BOUNDARY: // 1. top-level pool is popped, leaving the cold page in place // 2. an object is autoreleased with no pool } else { // Error. For bincompat purposes this is not // fatal in executables built with old SDKs. return badPop(token); } } //打印 hiwat if (PrintPoolHiwat) printHiwat(); page->releaseUntil(stop); // memory: delete empty children if (DebugPoolAllocation && page->empty()) { // special case: delete everything during page-per-pool debugging AutoreleasePoolPage *parent = page->parent; page->kill(); setHotPage(parent); } else if (DebugMissingPools && page->empty() && !page->parent) { // special case: delete everything for pop(top) // when debugging missing autorelease pools page->kill(); setHotPage(nil); } else if (page->child) { // hysteresis: keep one empty child if page is more than half full if (page->lessThanHalfFull()) { page->child->kill(); } else if (page->child->child) { page->child->child->kill(); } } } static void init() { int r __unused = pthread_key_init_np(AutoreleasePoolPage::key, AutoreleasePoolPage::tls_dealloc); assert(r == 0); } //打印 void print() { _objc_inform("[%p] ................ PAGE %s %s %s", this, full() ? "(full)" : "", this == hotPage() ? "(hot)" : "", this == coldPage() ? "(cold)" : ""); check(false); for (id *p = begin(); p < next; p++) { if (*p == POOL_BOUNDARY) { _objc_inform("[%p] ################ POOL %p", p, p); } else { _objc_inform("[%p] %#16lx %s", p, (unsigned long)*p, object_getClassName(*p)); } } } //打印所有 static void printAll() { _objc_inform("##############"); _objc_inform("AUTORELEASE POOLS for thread %p", pthread_self()); AutoreleasePoolPage *page; ptrdiff_t objects = 0; for (page = coldPage(); page; page = page->child) { objects += page->next - page->begin(); } _objc_inform("%llu releases pending.", (unsigned long long)objects); if (haveEmptyPoolPlaceholder()) { _objc_inform("[%p] ................ PAGE (placeholder)", EMPTY_POOL_PLACEHOLDER); _objc_inform("[%p] ################ POOL (placeholder)", EMPTY_POOL_PLACEHOLDER); } else { for (page = coldPage(); page; page = page->child) { page->print(); } } _objc_inform("##############"); } //打印 hiwat static void printHiwat() { // Check and propagate high water mark // Ignore high water marks under 256 to suppress noise. AutoreleasePoolPage *p = hotPage(); uint32_t mark = p->depth*COUNT + (uint32_t)(p->next - p->begin()); if (mark > p->hiwat && mark > 256) { for( ; p; p = p->parent) { p->unprotect(); p->hiwat = mark; p->protect(); } _objc_inform("POOL HIGHWATER: new high water mark of %u " "pending releases for thread %p:", mark, pthread_self()); void *stack[128]; int count = backtrace(stack, sizeof(stack)/sizeof(stack[0])); char **sym = backtrace_symbols(stack, count); for (int i = 0; i < count; i++) { _objc_inform("POOL HIGHWATER: %s", sym[i]); } free(sym); } } #undef POOL_BOUNDARY }; 参考文章 官方文档

作者:Mitchell
链接:http://www.jianshu.com/p/554c9fe0f041
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

共有 人打赏支持
粉丝 0
博文 97
码字总数 43538
×
HeroHY
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: