iOS 中 weak 的实现
只要学过 iOS 的人,都会对 strong、weak、copy等关键字应该都会很熟悉。weak 属性关键字就是弱引用,它不会增加引用计数但却能保证指针的安全访问,在对象释放后置为 nil,从而避免错误的内存访问。主要为了解决循环引用的问题。
接下来,我们会从 objc 库中的 NSObject.mm、 objc-weak.h 以及 objc-weak.mm 文件出发,去具体了解 weak 的实现过程。
weak 的内部结构
Runtime 维护了一个weak表,用于存储指向某个对象的所有weak指针。weak 表是由单个自旋锁管理的散列表。
weak表其实是一个hash表,key 是所指对象的指针,value是weak指针的地址(这个地址的值是所指向对象的地址)数组。
在下面涉及的源码中,我们会看到以下几个类型:
sideTable、weak_table_t、weak_entry_t 这几个结构体。
struct SideTable {// 自旋锁,用来保证线程安全spinlock_t slock;// 引用计数表RefcountMap refcnts;// weak 表weak_table_t weak_table;...
};
SideTable,它用来管理引用计数表和 weak 表,并使用 spinlock_lock 自旋锁来防止操作表结构时可能的竞态条件。它用一个 64*128 大小的uint8_t 静态数组作为 buffer 来保存所有的 SideTable 实例。这个结构体里面包含三个变量,第一个spinlock_t
,它是一个自旋锁,用来保证线程安全。第二个RefcountMap
,是引用计数表,每个对象的引用计数保存在全局的引用计数表中,一个对象地址对应一个引用计数。第三个就是我们接下来要讲的 weak 表,所有的 weak 变量会被加入到全局的weak表中,表的 key 是 weak 修饰的变量指向的对象, value 值就是 weak 修饰的变量。接下来,我们具体看看这个 weak 表
struct weak_table_t {// 保存了所有指向指定对象的 weak 指针weak_entry_t *weak_entries;// 存储空间,即 entries 的数目size_t num_entries;// 参与判断引用计数辅助量uintptr_t mask;// hash key 最大偏移量uintptr_t max_hash_displacement;
};
这个是全局弱引用的 hash 表。它的作用就是在对象执行 dealloc 的时候将所有指向该对象的 weak 指针的值设为 nil, 避免悬空指针。它使用不定类型对象的地址的 hash 化后的数值作为 key,用 weak_entry_t
类型的结构体对象作为 value。其中 weak_entry_t
是存储在弱引用表中的一个内部结构体,它负责维护和存储指向一个对象的所有弱引用 hash 表。其定义如下:
// 存储在弱引用表中的一个内部结构体
#define WEAK_INLINE_COUNT 4
struct weak_entry_t {DisguisedPtr<objc_object> referent; // 封装 objc_object 指针,即 weak 修饰的变量指向的对象union {struct {weak_referrer_t *referrers;uintptr_t out_of_line : 1; // LSB 最低有效元 当标志位为0时,增加引用表指针纬度,// 当其为0的时候, weak_referrer_t 成员将扩展为静态数组型的 hash tableuintptr_t num_refs : PTR_MINUS_1; // 引用数值,这里记录弱引用表中引用有效数字,即里面元素的数量uintptr_t mask;uintptr_t max_hash_displacement; // hash 元素上限阀值};struct {// out_of_line=0 is LSB of one of these (don't care which)weak_referrer_t inline_referrers[WEAK_INLINE_COUNT]; };};
};
在 weak_entry_t
的结构中, DisguisedPtr<objc_object>
是对 objc_object *
指针及其一些操作进行的封装,目的就是为了让它给人看起来不会有内存泄露的样子,其内容可以理解为对象的内存地址。out_of-line
成员为最低有效位,当其为 0 的时候,weak_referrer_t
成员将扩展为一个静态数组型的 hash table。其实 weak_referrer
是objc_objcet 的别名,定义如下:typedef objc_object ** weak_referrer_t;
它通过一个二维指针地址偏移,用下标作为 hash 的 key,做成了一个弱引用散列。
下图,就是weak表结构的总结:
每个对象的 SideTable 中的 weak_table_t 都是全局 weak 表的入口,以引用计数对象为键找到其所记录的 weak 修饰的对象。weak_entry_t 中的 referrers 有两种形式,当 out_of_line 为 0 的时候,referrers 是一个静态数组型的表,数组大小默认为 WEAK_INLINE_COUNT 大小,当 out_of_line 不为 0 的时候,referrers 是一个动态数组,内容随之增加。
weak 实现原理的过程
当我们用 weak 修饰属性的时候,它是怎么实现当所引用的对象被废弃的时候,变量置为 nil,我们来探究一下。
{id obj1 = [[NSObject alloc] init];id __weak obj2 = obj1;
}
经过编译期转换之后,以上代码会变成下面这样
id obj2;
objc_initWeak(&obj2, obj1);
objc_destroyWeak(&obj2);
我们发现,weak 修饰符变量是通过 objc_initWeak 函数来初始化的,在变量作用域结束的时候通过 objc_destroyWeak 函数来释放该变量的。接下来,我们看看这两个函数的源码。
id objc_initWeak(id *location, id newObj)
{// 查看对象实例是否有效// 无效对象直接导致指针释放if (!newObj) {*location = nil;return nil;}// 这里传递了三个 bool 数值// 使用 template 进行常量参数传递是为了优化性能return storeWeak<false/*old*/, true/*new*/, true/*crash*/>(location, (objc_object*)newObj);
}
void objc_destroyWeak(id *location)
{(void)storeWeak<true/*old*/, false/*new*/, false/*crash*/>(location, nil);
}
对这两个方法的分析后,我们发现它们都调用了storeWeak 这个函数,但是两个方法传入的参数却稍有不同。
init 方法中,第一个参数为 weak 修饰的变量,第二个参数为引用计数对象。但在 destoryWeak 函数,第一参数依旧为 weak 修饰的变量,第二个参数为 nil。那这块传入不同的参数到底代表什么,我们继续分析 storeWeak 这个函数。
// 更新一个弱引用变量
// 如果 HaveOld 是 true, 变量是个有效值,需要被及时清理。变量可以为 nil。
// 如果 HaveNew 是 true, 需要一个新的 value 来替换变量。变量可以为 nil
// 如果crashifdeallocation 是 ture ,那么如果 newObj 是 deallocating,或者 newObj 的类不支持弱引用,则该进程就会停止。
// 如果crashifdeallocation 是 false,那么 nil 会被存储。template <bool HaveOld, bool HaveNew, bool CrashIfDeallocating>
static id storeWeak(id *location, objc_object *newObj)
{assert(HaveOld || HaveNew);if (!HaveNew) assert(newObj == nil);Class previouslyInitializedClass = nil;id oldObj;// 创建新旧散列表SideTable *oldTable;SideTable *newTable;// Acquire locks for old and new values.// 获得新值和旧值的锁存位置 (用地址作为唯一标示)// Order by lock address to prevent lock ordering problems.// 通过地址来建立索引标志,防止桶重复// Retry if the old value changes underneath us.// 下面指向的操作会改变旧值retry:if (HaveOld) {// 如果 HaveOld 为 true ,更改指针,获得以 oldObj 为索引所存储的值地址oldObj = *location;oldTable = &SideTables()[oldObj];} else {oldTable = nil;}if (HaveNew) {// 获得以 newObj 为索引所存储的值对象newTable = &SideTables()[newObj];} else {newTable = nil;}// 对两个 table 进行加锁操作,防止多线程中竞争冲突SideTable::lockTwo<HaveOld, HaveNew>(oldTable, newTable);// location 应该与 oldObj 保持一致,如果不同,说明当前的 location 已经处理过 oldObj 可是又被其他线程所修改, 保证线程安全,这个判断用来避免线程冲突重处理问题if (HaveOld && *location != oldObj) {SideTable::unlockTwo<HaveOld, HaveNew>(oldTable, newTable);goto retry;}// Prevent a deadlock between the weak reference machinery// and the +initialize machinery by ensuring that no // weakly-referenced object has an un-+initialized isa.// 防止弱引用之间发生死锁,并且通过 +initialize 初始化构造器保证所有弱引用的 isa 非空指向if (HaveNew && newObj) {// 获得新对象的 isa 指针Class cls = newObj->getIsa();// 判断 isa 非空且已经初始化if (cls != previouslyInitializedClass && !((objc_class *)cls)->isInitialized()) {// 对两个表解锁SideTable::unlockTwo<HaveOld, HaveNew>(oldTable, newTable);_class_initialize(_class_getNonMetaClass(cls, (id)newObj));// If this class is finished with +initialize then we're good.// If this class is still running +initialize on this thread // (i.e. +initialize called storeWeak on an instance of itself)// then we may proceed but it will appear initializing and // not yet initialized to the check above.// Instead set previouslyInitializedClass to recognize it on retry.// 如果该类已经完成执行 +initialize 方法是最好的,如果该类 + initialize 在线程中,例如 +initialize 正在调用storeWeak 方法,那么则需要手动对其增加保护策略,并设置 previouslyInitializedClass 指针进行标记然后重新尝试previouslyInitializedClass = cls;goto retry;}}// Clean up old value, if any. 清除旧值if (HaveOld) {weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);}// Assign new value, if any. 分配新值if (HaveNew) {newObj = (objc_object *)weak_register_no_lock(&newTable->weak_table, (id)newObj, location, CrashIfDeallocating);// weak_register_no_lock returns nil if weak store should be rejected// 如果弱引用被释放则该方法返回 nil// Set is-weakly-referenced bit in refcount table.// 在引用计数表中设置弱引用标记位if (newObj && !newObj->isTaggedPointer()) {newObj->setWeaklyReferenced_nolock();}// Do not set *location anywhere else. That would introduce a race.*location = (id)newObj;}else {// No new value. The storage is not changed.}SideTable::unlockTwo<HaveOld, HaveNew>(oldTable, newTable);return (id)newObj;
}
以上就是 store_weak 这个函数的实现,它主要做了以下几件事:
- 声明了新旧散列表指针,因为 weak 修饰的变量如果之前已经指向一个对象,然后其再次改变指向另一个对象,那么按理来说我们需要释放旧对象中该 weak 变量的记录,也就是要将旧记录删除,然后在新记录中添加。这里的新旧散列表就是这个作用。
- 根据新旧变量的地址获取相应的 SideTable
- 对两个表进行加锁操作,防止多线程竞争冲突
- 进行线程冲突重处理判断
- 判断其 isa 是否为空,为空则需要进行初始化
- 如果存在旧值,调用 weak_unregister_no_lock 函数清除旧值
- 调用 weak_register_no_lock 函数分配新值
- 解锁两个表,并返回第二参数
初始化弱引用对象流程一览
弱引用的初始化,从上文的分析可以看出,主要的操作部分就是在弱引用表的取键、查询散列、创建弱引用等操作,可以总结出如下的流程图:
旧对象解除注册操作 weak_unregister_no_lock
void weak_unregister_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id)
{objc_object *referent = (objc_object *)referent_id;objc_object **referrer = (objc_object **)referrer_id;weak_entry_t *entry;if (!referent) return;if ((entry = weak_entry_for_referent(weak_table, referent))) {remove_referrer(entry, referrer);bool empty = true;if (entry->out_of_line && entry->num_refs != 0) {empty = false;}else {for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {if (entry->inline_referrers[i]) {empty = false; break;}}}if (empty) {weak_entry_remove(weak_table, entry);}}// Do not set *referrer = nil. objc_storeWeak() requires that the // value not change.
}
该方法主要作用是将旧对象在 weak_table 中接触 weak 指针的对应绑定。根据函数名,称之为解除注册操作。
来看看这个函数的逻辑。首先参数是 weak_table_t
表,键和值。声明 weak_entry_t
变量,如果key,也就是引用计数对象为空,直接返回。根据全局入口表和键获取对应的 weak_entry_t
对象,也就是 weak 表记录。获取到记录后,将记录表以及 weak 对象作为参数传入 remove_referrer
函数中,这个函数就是解除操作。然后判断这个 weak 记录是否为空,如果为空,从全局记录表中清除相应的引用计数对象的 weak 记录表。
接下来,我们了解一下,如何获取这个 weak_entry_t 这个变量。
static weak_entry_t *weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent)
{assert(referent);weak_entry_t *weak_entries = weak_table->weak_entries;if (!weak_entries) return nil;size_t index = hash_pointer(referent) & weak_table->mask;size_t hash_displacement = 0;while (weak_table->weak_entries[index].referent != referent) {index = (index+1) & weak_table->mask;hash_displacement++;if (hash_displacement > weak_table->max_hash_displacement) {return nil;}}return &weak_table->weak_entries[index];
}
这个函数的逻辑就是先获取全局 weak 表入口,然后将引用计数对象的地址进行 hash 化后与 weak_table->mask 做与操作,作为下标,在全局 weak 表中查找,若找到,返回这个对象的 weak 记录表,若没有,返回nil。
再来了解一下解除对象的函数:
static void remove_referrer(weak_entry_t *entry, objc_object **old_referrer)
{if (! entry->out_of_line) {for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {if (entry->inline_referrers[i] == old_referrer) {entry->inline_referrers[i] = nil;return;}}_objc_inform("Attempted to unregister unknown __weak variable ""at %p. This is probably incorrect use of ""objc_storeWeak() and objc_loadWeak(). ""Break on objc_weak_error to debug.\n", old_referrer);objc_weak_error();return;}size_t index = w_hash_pointer(old_referrer) & (entry->mask);size_t hash_displacement = 0;while (entry->referrers[index] != old_referrer) {index = (index+1) & entry->mask;hash_displacement++;if (hash_displacement > entry->max_hash_displacement) {_objc_inform("Attempted to unregister unknown __weak variable ""at %p. This is probably incorrect use of ""objc_storeWeak() and objc_loadWeak(). ""Break on objc_weak_error to debug.\n", old_referrer);objc_weak_error();return;}}entry->referrers[index] = nil;entry->num_refs--;
}
这个函数传入的是 weak 对象,当 out_of_line 为0 时,遍历数组,找到对应的对象,置nil,如果未找到,报错并返回。当 out_of_line 不为0时,根据对象的地址 hash 化并和 mask 做与操作作为下标,查找相应的对象,若没有,报错并返回,若有,相应的置为 nil,并减少元素数量,即 num_refs 减 1。
新对象添加注册操作 weak_register_no_lock
id weak_register_no_lock(weak_table_t *weak_table, id referent_id, id *referrer_id, bool crashIfDeallocating)
{objc_object *referent = (objc_object *)referent_id;objc_object **referrer = (objc_object **)referrer_id;if (!referent || referent->isTaggedPointer()) return referent_id;// ensure that the referenced object is viablebool deallocating;if (!referent->ISA()->hasCustomRR()) {deallocating = referent->rootIsDeallocating();}else {BOOL (*allowsWeakReference)(objc_object *, SEL) = (BOOL(*)(objc_object *, SEL))object_getMethodImplementation((id)referent, SEL_allowsWeakReference);if ((IMP)allowsWeakReference == _objc_msgForward) {return nil;}deallocating =! (*allowsWeakReference)(referent, SEL_allowsWeakReference);}if (deallocating) {if (crashIfDeallocating) {_objc_fatal("Cannot form weak reference to instance (%p) of ""class %s. It is possible that this object was ""over-released, or is in the process of deallocation.",(void*)referent, object_getClassName((id)referent));} else {return nil;}}// now remember it and where it is being storedweak_entry_t *entry;if ((entry = weak_entry_for_referent(weak_table, referent))) {append_referrer(entry, referrer);} else {weak_entry_t new_entry;new_entry.referent = referent;new_entry.out_of_line = 0;new_entry.inline_referrers[0] = referrer;for (size_t i = 1; i < WEAK_INLINE_COUNT; i++) {new_entry.inline_referrers[i] = nil;}weak_grow_maybe(weak_table);weak_entry_insert(weak_table, &new_entry);}// Do not set *referrer. objc_storeWeak() requires that the // value not change.return referent_id;
}
一大堆 if-else, 主要是为了判断该对象是不是 taggedPoint 以及是否正在调用 dealloca 等。下面操作开始,同样是先获取 weak 表记录,如果获取到,则调用 append_referrer 插入对象,若没有,则新建一个 weak 表记录,默认为 out_of_line,然后将新对象放到 0 下标位置,其他位置置为 nil 。下面两个函数 weak_grow_maybe 是用来判断是否需要重申请内存重 hash,weak_entry_insert 函数是用来将新建的 weak 表记录插入到全局 weak 表中。插入时同样是以对象地址的 hash 化和 mask 值相与作为下标来记录的。
接下来看看 append_referrer 函数,源代码如下:
static void append_referrer(weak_entry_t *entry, objc_object **new_referrer)
{if (! entry->out_of_line) {// Try to insert inline.for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {if (entry->inline_referrers[i] == nil) {entry->inline_referrers[i] = new_referrer;return;}}// Couldn't insert inline. Allocate out of line.weak_referrer_t *new_referrers = (weak_referrer_t *)calloc(WEAK_INLINE_COUNT, sizeof(weak_referrer_t));// This constructed table is invalid, but grow_refs_and_insert// will fix it and rehash it.for (size_t i = 0; i < WEAK_INLINE_COUNT; i++) {new_referrers[i] = entry->inline_referrers[i];}entry->referrers = new_referrers;entry->num_refs = WEAK_INLINE_COUNT;entry->out_of_line = 1;entry->mask = WEAK_INLINE_COUNT-1;entry->max_hash_displacement = 0;}assert(entry->out_of_line);if (entry->num_refs >= TABLE_SIZE(entry) * 3/4) {return grow_refs_and_insert(entry, new_referrer);}size_t index = w_hash_pointer(new_referrer) & (entry->mask);size_t hash_displacement = 0;while (entry->referrers[index] != NULL) {index = (index+1) & entry->mask;hash_displacement++;}if (hash_displacement > entry->max_hash_displacement) {entry->max_hash_displacement = hash_displacement;}weak_referrer_t &ref = entry->referrers[index];ref = new_referrer;entry->num_refs++;
}
当 out_of_line 为 0,并且静态数组里面还有位置存放,那么直接存放并返回。如果没有位置存放,则升级为动态数组,并加入。如果 out_of_line 不为 0,先判断是否需要扩容,然后同样的,使用对象地址的 hash 化和 mask 做与操作作为下标,找到相应的位置并插入。
对象的销毁以及 weak 的置 nil 实现
释放时,调用clearDeallocating
函数。clearDeallocating
函数首先根据对象地址获取所有weak指针地址的数组,然后遍历这个数组把其中的数据设为nil,最后把这个entry从weak表中删除,最后清理对象的记录。
当weak引用指向的对象被释放时,又是如何去处理weak指针的呢?当释放对象时,其基本流程如下:
- 调用 objc_release
- 因为对象的引用计数为0,所以执行dealloc
- 在dealloc 中,调用了_objc_rootDealloc 函数
- 在 _objc_rootDealloc 中,调用了 objec_dispose 函数
- 调用objc_destructInstance
- 最后调用 objc_clear_deallocating
objc_clear_deallocating的具体实现如下:
void objc_clear_deallocating(id obj)
{assert(obj);assert(!UseGC);if (obj->isTaggedPointer()) return;obj->clearDeallocating();
}
这个函数只是做一些判断以及更深层次的函数调用,
void objc_object::sidetable_clearDeallocating()
{SideTable& table = SideTables()[this];// clear any weak table items// clear extra retain count and deallocating bit// (fixme warn or abort if extra retain count == 0 ?)table.lock();// 迭代器RefcountMap::iterator it = table.refcnts.find(this);if (it != table.refcnts.end()) {if (it->second & SIDE_TABLE_WEAKLY_REFERENCED) {weak_clear_no_lock(&table.weak_table, (id)this);}table.refcnts.erase(it);}table.unlock();
}
我们可以看到,在这个函数中,首先取出对象对应的SideTable实例,如果这个对象有关联的弱引用,则调用weak_clear_no_lock
来清除对象的弱引用信息,我们在来深入一下,
void weak_clear_no_lock(weak_table_t *weak_table, id referent_id)
{objc_object *referent = (objc_object *)referent_id;weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);if (entry == nil) {/// XXX shouldn't happen, but does with mismatched CF/objc//printf("XXX no entry for clear deallocating %p\n", referent);return;}// zero out referencesweak_referrer_t *referrers;size_t count;if (entry->out_of_line) {referrers = entry->referrers;count = TABLE_SIZE(entry);} else {referrers = entry->inline_referrers;count = WEAK_INLINE_COUNT;}for (size_t i = 0; i < count; ++i) {objc_object **referrer = referrers[i];if (referrer) {if (*referrer == referent) {*referrer = nil;}else if (*referrer) {_objc_inform("__weak variable at %p holds %p instead of %p. ""This is probably incorrect use of ""objc_storeWeak() and objc_loadWeak(). ""Break on objc_weak_error to debug.\n", referrer, (void*)*referrer, (void*)referent);objc_weak_error();}}}weak_entry_remove(weak_table, entry);
}
这个函数根据 out_of_line 的值,取得对应的记录表,然后根据引用计数对象,将相应的 weak 对象置 nil。最后清除相应的记录表。
通过上面的描述,我们基本能了解一个weak引用从生到死的过程。从这个流程可以看出,一个weak引用的处理涉及各种查表、添加与删除操作,还是有一定消耗的。所以如果大量使用__weak变量的话,会对性能造成一定的影响。那么,我们应该在什么时候去使用weak呢?《Objective-C高级编程》给我们的建议是只在避免循环引用的时候使用__weak修饰符。
iOS 中 weak 的实现相关推荐
- iOS 中delegate的理解与使用(传值)
之前做了半年的iOS,刚入了门,又被拉去转战java,现在iOS的那位大佬离职了,又被弄过来维护app,之前对于iOS中的delegate一直都是半知半解,所以刚好趁着这个机会把我所了解的记下来,以便 ...
- iOS中的传感器---摇一摇, 计步器,距离感应,陀螺仪
前几天项目中用到了一下CoreMotion框架,觉得iOS中的传感器还是挺好玩的,又花了点时间去了解了一下iOS中其他一些常用的传感器应用,今天简单做下总结. iOS中的传感器大致有以下几种: 运动传 ...
- [iOS开发]iOS中的Hash
文章目录 前言 关联对象的底层原理 weak的实现原理 KVO的实现原理 iOS App签名的原理 对象引用计数存储的位置 Runloop与线程的存储关系 NSDictionary的原理 哈希表 哈希 ...
- iOS arc weak指针原理
iOS arc weak指针原理 ARC 都帮我们做了什么? weak是什么? weak是怎么实现的? 1. weak原理简介 2. weak简单测试 3. weak原理分析 3.1 weak指针帮我 ...
- iOS中 流媒体播放和下载 韩俊强的博客
iOS中关于流媒体的简介:介于下载本地播放与实时流媒体之间的一种播放形式,下载本地播放必须全部将文件下载完成后才能播放,而渐进式下载不必等到全部下载完成后再播放,它可以一边下载一边播放,在完成播放内容 ...
- 【iOS】—— iOS中的相关锁
文章目录 自旋锁 1.OSSpinLock 2.os_unfair_lock 3.atomic 互斥锁 pthread_mutex @synchronized objc_sync_enter objc ...
- iOS 中的 timer 任务(寻找内存恶鬼之旅)
前言 在 iOS 的开发过程中定时任务中能找到使用的场景,然而在 iOS 中默认的有关 timer 的 api 总是那么晦涩难用,而且暗坑不断,一旦遇上,会让你一脸懵逼,为了不再同一个地方跌倒两次,我 ...
- iOS中常用的设计模式
iOS中常用的几种设计模式 iOS中常用的几种设计模式 1.代理模式 2. 观察者模式(通知机制,KVO机制) 4. 单例 5. 适配器() 6. 策略 9. 装饰器(Decorator) 10. 原 ...
- ios中使用SegmentedControl来切换视图
From 效果图 设计图 结构与原理 视图结构 切换视图原理 代码 From ios中使用SegmentedControl来切换视图 效果图 设计图 结构与原理 视图结构 共有3个ViewContro ...
最新文章
- HTTP基础认证Basic Authentication
- android 定位 闪退_Android使用百度地图出现闪退及定位时显示蓝屏问题
- Spring Cloud配置–外部化应用程序配置
- RHEL5.8配置开机自动挂载磁盘
- 20151212Jquery 工具函数代码备份
- qq修改群名服务器失败,如何解决qq群名片改不了的问题
- 小米8手机android版本下载地址,小米手机8 MIUI 10稳定版完整包发布(附下载链接)...
- 创邻科技荣登机器之心Pro·AI 趋势先锋 Insight 榜单
- tensorflow2系类知识-4 :RNN
- 非常详细的图文安装wordpress安装教程
- python爬虫:lxml爬取链家网二手房信息
- 数字电路 时序逻辑电路
- 写入iCloud在模拟器和真机上失败的解决办法
- jsp+servlet搭建在线投票问卷系统
- C/C++描述 第十一届蓝桥杯省赛 C/C++ 大学C组 第一场(2020.7.5) 题目+题解
- 【解题总结】SEERC 2019(Codeforces Gym 102392)
- 你好,法语!A1课文背诵汇总
- symfony框架Twig模板语言的使用
- android 选择答题功能,Android实现选择题答题(包括单选、多选和答题卡)
- 中间件weblogic部署详情