学习笔记

653 阅读45分钟

一、weak的原理

 weak_entry_t:存储了引用对象的所有 weak 指针的信息。

 可以看作是哈希表 weak_table_t 的一个元素,key 是 referent,value 是对应的 referrers。

#define WEAK_INLINE_COUNT 4
#define REFERRERS_OUT_OF_LINE 2

struct weak_entry_t {
    DisguisedPtr<objc_object> referent; // 被弱引用的对象

    // 引用该对象的对象列表,即 weak 指针列表。若引用个数小于 WEAK_INLINE_COUNT,使用inline_referrers数组。否则使用动态数组weak_referrer_t *referrers
    union {
        struct {
            weak_referrer_t *referrers;                      // 弱引用该对象的 weak 指针的地址的数组。由于 weak 指针指向被引用对象,因此指针中存储的便是 referent(即 *(referrers[i])==referent)
            uintptr_t        out_of_line_ness : 2;           // 是否使用动态hash数组标记位
            uintptr_t        num_refs : PTR_MINUS_2;         // hash数组中的元素个数
            uintptr_t        mask;                           // hash数组长度-1,会参与hash计算。(注意,这里是hash数组的长度,而不是元素个数。比如,数组长度可能是64,而元素个数为2)。
            uintptr_t        max_hash_displacement;          // 可能会发生的hash冲突的最大次数,用于判断是否出现了逻辑错误(hash表中的冲突次数绝不会超过该值)
        };
        struct {
            // out_of_line_ness field is low bits of inline_referrers[1]
            weak_referrer_t  inline_referrers[WEAK_INLINE_COUNT];
        };
    };

    bool out_of_line() {
        return (out_of_line_ness == REFERRERS_OUT_OF_LINE);
    }

    weak_entry_t& operator=(const weak_entry_t& other) {
        memcpy(this, &other, sizeof(other));
        return *this;
    }

    weak_entry_t(objc_object *newReferent, objc_object **newReferrer)
        : referent(newReferent) // 构造方法,里面初始化了静态数组
    {
        inline_referrers[0] = newReferrer;
        for (int i = 1; i < WEAK_INLINE_COUNT; i++) {
            inline_referrers[i] = nil;
        }
    }
};  

 weak_table_t:存储了所有被弱引用的对象的 weak_entry_t 信息。

 可看作哈希表,根据 referent 来查找对应的 referrers。

struct weak_table_t {
    weak_entry_t *weak_entries;
    size_t    num_entries;
    uintptr_t mask;
    uintptr_t max_hash_displacement;
};

 SideTable:用于保证线程安全,原 weak_table_t 不是线程安全的。

struct SideTable {
    spinlock_t slock;
    RefcountMap refcnts;
    weak_table_t weak_table;
}

 处理步骤:

 1)调用 objc_initWeak 方法,其内部调用了 storeWeak 方法。

 2)在 storeWeak 方法中进行处理。

 若 weak 指针之前指向一个对象,则调用 weak_unregister_no_lock 方法移除对应的旧 weak 指针:

  • 首先,会在 weak_table_t 中找出 referent 对应的 weak_entry_t ;
  • 在weak_entry_t 中的 referrers 数组中移除 referrer ;
  • 移除元素后,判断此时 weak_entry_t 中的 referrers 数组是否为空;
  • 如果此时 referrers 数组为空,则将 weak_entry_t 从 weak_table_t 中移除。

 通过调用 weak_register_no_lock 方法将 weak 指针指向新对象:

  • 首先进行特殊情况处理。
  • 若对象不是正在被析构,并且可以被弱引用,则调用weak_entry_for_referent 方法根据弱引用对象的地址(referent)从弱引用表(weak_table_t)中查找对应的weak_entry_t,如果能够找到则调用append_referrer 方法向referrers 数组插入 weak 指针地址。否则新建一个weak_entry,并插入 weak 指针地址。

 3)当释放对象时,调用clearDeallocating函数根据对象地址(referent)获取对应的 referrers 数组。然后遍历此数组,把 weak 指针设为指向nil,最后从 weak_table_t 中删除对应的 weak_entry_t。

二、block 的原理

    block 是一个封装了函数调用以及函数调用环境的对象。

    block 中的代码块在编译时会被转换为函数。

int main(int argc, char * argv[]) {
    void (^block)() = ^{
        //do something
    };
    block();
    return 0;
}

 对应的代码:

struct __block_impl {
  void *isa;  //存储对象自身的地址
  int Flags;
  int Reserved;
  void *FuncPtr;  //存储block中的代码块的地址,即 __main_block_func_0 的地址
};


struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;  //存储 block 的描述信息
  }
};

//block 中的代码块会被转换为函数
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

}

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};

int main(int argc, char * argv[]) {
    //block 指向 __main_block_impl_0 结构体
    void (*block)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
    //调用 block
    ((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);
    return 0;
}

三、load 与 initialize

1.load 方法

    当启动程序时,编译之后的类与类别都会被加载进内存,若类实现了 +load 方法,则当类被加载进内存时(即使此后该类未被使用),系统会自动调用此方法。调用 load 方法是直接根据方法的内存地址进行调用,而不是使用消息发送机制。

    调用顺序:

  • 先执行所有类的load方法,再执行所有类别的load方法。
  • 按照参与编译的顺序执行类的load方法,先编译的类先执行,但是如果某个类是继承自另一个类,那么会先执行父类的load方法再执行子类的load方法。
  • 按照参与编译的顺序执行类别的load方法,先编译的类别先执行。

2.initialize 方法

    当类或类的子类收到第一条消息时,系统会调用 initialize 方法,一个类只会调用一次该方法。若某个类一直未被使用,则系统不会调用其 initialize 方法。调用机制是消息发送机制。

    调用顺序:

  •  如果一个类和它的类别都实现了initialize方法,则最终调用的会是类别中的方法。
  • 如果子类和父类都实现了initialize方法,那么会先调用父类的方法,然后调用子类的方法(子类中不需要写[super initialize]来调用父类的方法,因为在底层实现过程中会主动调用父类的initialize方法)。

四、动态创建类

官方文档

void impsubmethod(id self, SEL _cmd) {
    NSLog(@"Hi, this is the impsubmethod");
}

void submethod() {
    NSLog(@"Hi, this is the submethod");
}

void method() {
    NSLog(@"Hi, this is the method");
}

int main(int argc, char * argv[]) {
    Class MySubClass = objc_allocateClassPair(NSString.class, "MySubClass", 0);  // 参数:父类、所创建的类名称、额外的字节数
    
    class_addMethod(MySubClass, @selector(submethod), (IMP)impSubmethod, "v@:"); // 参数:被添加方法的类、方法的对外接口、方法的内部实现(实现与接口可以不同名)、表示方法的返回类型与参数类型
    class_replaceMethod(MySubClass, @selector(method), (IMP)impSubmethod, "v@:");
    
    class_addIvar(MySubClass, "_ivar1", sizeof(NSString *), log(sizeof(NSString *)), "i");  //参数:类名、变量名、变量尺寸、对齐量、变量类型
    
    objc_property_attribute_t type = {"T", "@"NSString""};  // objc_property_attribute_t 是结构体,结构体第一个参数是属性名,第二个参数是属性的值
    objc_property_attribute_t ownership = { "C", "" };
    objc_property_attribute_t backingivar = { "V", "_ivar1"};
    objc_property_attribute_t attrs[] = {type, ownership, backingivar};
    class_addProperty(MySubClass, "property2", attrs, 3);  // 参数:类名、属性名称、属性数组、属性数组的尺寸
    
    objc_registerClassPair(MySubClass);
    
    id instance = [[MySubClass alloc] init];
    // [instance submethod]; 若未实现submethod方法,则该语句编译不通过,但是下述语句可以编译通过
    [instance performSelector:@selector(submethod)];
    [instance performSelector:@selector(method)];
    
    objc_disposeClassPair(MySubClass);  // 销毁类,但是若后续再次使用了当前类或当前类的子类,则程序会崩溃
    return 0;
}

五、内存泄漏

    内存泄漏(memory leak):指申请的内存空间使用完毕之后未被回收。

    内存溢出(out of memory):指程序在申请内存时,没有足够的内存空间供其使用。

5.1 内存泄漏

5.1.1 循环引用

 当对象 A 强引用对象 B,而对象 B 又强引用对象 A,或者多个对象互相强引用形成一个闭环时,便产生了循环引用。

  • 普通对象之间的循环引用:

 解决方法是将对象之间引用环的某一强引用(strong)改为弱引用(weak)。

// ClassB
@property (nonatomic, weak) ClassA *a;

// ClassA
@property (nonatomic, strong) ClassB *b;
  • block 循环引用:

 对象持有 block,而 block 中直接或间接地访问了该对象,从而造成循环引用;

 解决方法如下:

 1)先用 __weakself 置为弱引用,打破“循环”关系,但是假如 block 内有大量耗时操作,那么在 block 没有执行完成之前, weakSelf 便已经被释放,因此还需要在 block 内部,用 __strongweakSelf 进行强引用,这样可以确保 strongSelfblock 结束后才会被释放。

__weak typeof(self) weakSelf = self;
self.block = ^{
    __strong typeof(self) strongSelf = weakSelf;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", strongSelf.name);
    });
};
self.block();

 2)使用 __block 关键字修饰一个指针 ptr,使其指向 self,进而重新形成一个 self → block → ptr → self 的循环持有链。在调用结束后,将 ptr 置为 nil,就能断开循环持有链,从而令 self 正常释放。

__block UIViewController *ptr = self;
self.block = ^{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", ptr.name);
        ptr = nil;
    });
};
self.block();

 3)将 self 以传参的形式传入 block 内部,这样 self 就不会被 block 持用,因此也就不会形成循环持有链。

self.block = ^(UIViewController *ptr){
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", ptr.name);
    });
};
self.block(self);
  • NSTimer 循环引用:

 为了方便通常会把 NSTimer 声明为属性,像这样:

// 第一种创建方式,timer 默认添加进 runloop
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
// 第二种创建方式,需要手动将 timer 添加进 runloop
self.timer = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

 这就形成了 self → timer → self(target) 的循环持有链。只要 self 不释放,dealloc 就不会执行,timer 就无法在 dealloc 中销毁,但是由于self 始终被强引用,因此永远得不到释放,最终造成内存泄漏。

 如果只把 timer 作为局部变量,而不是属性呢?

NSTimer *timer = [NSTimer timerWithTimeInterval:1.0f target:self selector:@selector(timeFire) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

self 同样释放不了。因为在加入 runloop 的操作中,timer 被强引用,这就形成了一条 runloop → timer → self(target) 的持有链。而 timer 作为局部变量,无法执行 invalidate,所以在 timer 被销毁之前,self 也不会被释放。

 所以只要申请了 timer,加入了 runloop,并且 targetself,就算不是循环引用,也会造成内存泄漏,因为 self 没有释放的时机。

 解决方法:在合适的时机销毁 NSTimer。ViewController 中的时机可以选择 didMoveToParentViewControllerviewDidDisappearView 中可以选择 removeFromSuperview 等,但这种方案并一定是正确可行的,需要结合具体情况来具体分析。

[_timer invalidate];
_timer = nil;
  • delegate、dataSource 循环引用:

 常用的 tableViewViewController 就是委托方和代理方的关系。

 当需要在控制器中加入列表时,通常会将 tableView 设为 ViewControllerview 的子视图,UIViewController 的源码是这样定义 view 的:

@property(null_resettable, nonatomic, strong) UIView *view;

 因此 ViewController 强引用了 tableView。而 tableView 又要委托 ViewController 帮它实现几个代理方法和数据源方法。如果此时 dataSourcedelegate 属性用 strong 来修饰,就会出现 UITableViewViewController 互相强引用,进而形成循环引用。

 在 UITableView 的源码中,定义的 dataSourcedelegate 属性是用 weak 修饰的。

@property (nonatomic, weak, nullable) id <UITableViewDataSource> dataSource;
@property (nonatomic, weak, nullable) id <UITableViewDelegate> delegate;

tableViewdataSourcedelegate 只是 weak 指针,指向了 ViewController,它们之间的关系是这样的:

 这也就避免了循环引用的发生。

5.1.2 非 OC 对象内存处理

 CoreFoundation 框架下的某些对象或变量需要手动释放(如CGContextRelease);

 C 语言中的 malloc 等函数分配的内存,需要手动释放。

5.1.3 多次循环导致内存增长

 下面这段代码,看似没有内存泄漏的问题,但是在实际运行时,for 循环内部产生了大量的临时对象,会占用大量内存。这些临时对象,可能直至循环结束才被释放。

for (NSInteger i = 0; i < 1000000; i++) {
    NSString *str = @"Abc";
    str = [str lowercaseString];
    str = [str stringByAppendingString:@"xyz"];
    NSLog(@"%@", str);
}

 解决方案:

 在循环中创建 autoreleasepool,及时释放占用内存大的临时变量,减少内存占用。

for (NSInteger i = 0; i < 100000; i++) {
    @autoreleasepool {
        NSString *str = @"Abc";
        str = [str lowercaseString];
        str = [str stringByAppendingString:@"xyz"];
        NSLog(@"%@", str);
    }
}

在没有手动添加自动释放池的情况下,autorelease 对象是在当前的 runloop 迭代结束时释放的,而它能够释放的原因是系统在每个 runloop 迭代中都会先销毁并重新创建自动释放池。

下面举个特殊的例子,使用容器 block 版本的枚举器时,内部会自动添加一个自动释放池,比如:

[array enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
    // 这里被一个局部 @autoreleasepool 包围着
}];

5.2 内存泄漏分析工具

    静态分析工具:

        Xcode -> Product -> Analyze

  分析结果示例:

image.png

    动态分析工具:

        Xcode -> Product -> Profile, build 成功之后会弹出 instrument 工具,选择 Leaks 组件进行动态监测。

       Call Tree 四种选项具体含义:

        Separate by Thread:按线程分开做分析,这样更容易揪出那些吃资源的问题线程。特别是对于主线程,它要处理和渲染所有的接口数据,一旦受到阻塞,程序必然卡顿或停止响应。

        Invert Call Tree:反向输出调用树。把调用层级最深的方法显示在最上面,更容易找到最耗时的操作。

        Hide System Libraries:隐藏系统库文件。过滤掉各种系统调用,只显示自己的代码调用。

        Flattern Recursion:拼合递归。将同一递归函数产生的多条堆栈(因为递归函数会调用自己)合并为一条。

     MLeaksFinder:

        通过 pod 'MLeaksFinder' 或将下载的 MLeaksFinder 文件导入工程中,在 MLeaksFinder.h 中 设置是否开启内存泄漏检测,以及是在模拟器中开始还是在真机中

本节参考文章:

 1. iOS 内存泄漏场景与解决方案

 2. OC中内存泄漏

 3. iOS解除Block循环引用,你只知道__weak就out啦

六、单元测试(Unit Testing)

     又名模块测试,是针对程序模块来进行正确性检验的测试工作。在OOP中,程序模块一般指方法。

     Kiwi测试框架:

#import <Kiwi/Kiwi.h>
#import <SDBFoundation/SDBBiometricResult.h>

SPEC_BEGIN(SDBBiometricResultSpec)

describe(@"SDBBiometricResult", ^{

    // -resultWithError:
    context(@"create with cf error params", ^{

        context(@"using an empty cf error", ^{
            __block SDBBiometricResult *result = nil;
            beforeEach(^{
                result = [SDBBiometricResult resultWithError:NULL];
            });

            it(@"not be nil", ^{
                [[result shouldNot] beNil];
            });

            // other unit tests
        });

        context(@"using a ns->cf error", ^{
            __block SDBBiometricResult *result = nil;
            __block NSError *error = nil;
            beforeEach(^{
                error = [NSError errorWithDomain:NSCocoaErrorDomain code:-999 userInfo:nil];
                result = [SDBBiometricResult resultWithError:(__bridge CFErrorRef)error];
            });

            it(@"not be nil", ^{
                [[result shouldNot] beNil];
            });

            // other unit tests
        });

        context(@"using a real cf error", ^{
            __block SDBBiometricResult *result = nil;
            __block CFErrorRef error = NULL;
            beforeEach(^{
                error = CFErrorCreate(kCFAllocatorDefault, kCFErrorDomainCocoa, -999, NULL);
                result = [SDBBiometricResult resultWithError:error];
            });

            afterEach(^{
                CFRelease(error);
            });

            it(@"not be nil", ^{
                [[result shouldNot] beNil];
            });

            // other unit tests
        });
    });
});

SPEC_END 
// 通过宏定义实现了一个名为 name 的 KWSpec 的子类
SPEC_BEGIN(name)  SPEC_END  
// 具体语法
// 一个完整测试过程, 描述了要测试的类或一个主题(which)
void describe(NSString *aDescription, void (^block)(void)); 
// 一个局部的测试过程, 描述了在什么情形或条件下会怎么样或者是某种类型测试的概括,内嵌于 describe block 里 (when)
void context(NSString *aDescription, void (^block)(void));
// 单个方法的测试过程,一般包含多个输入参数的输出结果的验证;内嵌于 context block里 (it can/do/should...)
void it(NSString *aDescription, void (^block)(void)); 
// 及宏pending(title, args...)、xit(title, args...)用于描述尚未实现的测试方法
void pending_(NSString *aDescription, void (^ignoredBlock); 
// 在处于同一层级的每一个 it block调用前调用;可初始化测试类的实例,并赋一些属性满足其他block的测试准备
void beforeEach(void (^block)(void)); 
// 在处于同一层级的每一个 it block调用后调用,可用于恢复测试实例的状态或清理对象
void afterEach(void (^block)(void)); 
// 在处于同一层级的所有 it block调用前调用
void beforeAll(void (^block)(void)); 
// 在处于同一层级的所有 it block调用后调用
void afterAll(void (^block)(void)); 
// 声明一个变量,此变量会在每个测试用例运行之前重新初始化
let(id name, ^id(void)block);
//Expectations,Kiwi的GitHub网页上有详细的接口列表
[[subject should] someCondition:anArgument];

Mock:

    mock 遵循现有类的行为,模拟出对应的实例对象。该对象只能接收通过 stub 、receive 添加的消息,不能接收其他消息,否则会抛出异常。

    而通过 nullMock 模拟出的对象,可以接收所有消息且不会抛出异常。

    也可对协议进行 mock。

id carMock = [Car mock];
[ [carMock should] beMemberOfClass:[Car class]];
[ [carMock should] receive:@selector(currentGear) andReturn:theValue(3)];
[ [theValue(carMock.currentGear) should] equal:theValue(3)];

id carNullMock = [Car nullMock];
[ [theValue(carNullMock.currentGear) should] equal:theValue(0)];
[carNullMock applyBrakes];


id flyerMock = [KWMock mockForProtocol:@protocol(FlyingMachine)];
[ [flyerMock should] conformToProtocol:@protocol(FlyingMachine)];
[flyerMock stub:@selector(dragCoefficient) andReturn:theValue(17.0f)];

id flyerNullMock = [KWMock nullMockForProtocol:@protocol(FlyingMachine)];
[flyerNullMock takeOff];

Stub:

    stub 返回消息的固定响应,即可以指定消息的返回值。对于真实对象、mock出的对象均可使用。

    不要对 NSObject 类的方法、NSObject 协议的方法、Kiwi对象与方法进行 stub 操作。

id cruiser = [Cruiser cruiser];
[ [cruiser stubAndReturn:theValue(42.0f)] energyLevelInWarpCore:7];
float energyLevel = [cruiser energyLevelInWarpCore:7];
[ [theValue(energyLevel) should] equal:theValue(42.0f)];

[Cruiser stub:@selector(classification) andReturn:@"Not a moon"];
[ [ [Cruiser classification] should] equal:@"Not a moon"];

id mock = [Animal mock];
[mock stub:@selector(species) andReturn:@"P. tigris"];
[ [mock.species should] equal:@"P. tigris"];

Capturing Arguments:

    可通过该方法捕获传入某个方法的参数,以验证该参数的行为。

id robotMock = [KWMock nullMockForClass:[Robot class]];
KWCaptureSpy *spy = [robotMock captureArgument:@selector(speak:afterDelay:whenDone:) atIndex:2];

[[[robotMock should] receive] speak:@"Goodbye"];

[robotMock speak:@"Hello" afterDelay:2 whenDone:^{
    [robotMock speak:@"Goodbye"];
}];

void (^block)(void) = spy.argument;
block();

七、RunLoop

 RunLoop实际上是 __CFRunLoop 结构体:

struct __CFRunLoop { 
    CFRuntimeBase _base; 
    pthread_mutex_t _lock;  // locked for accessing mode list 
    __CFPort _wakeUpPort;  // used for CFRunLoopWakeUp 内核向该端口发送消息可以唤醒runloop 
    Boolean _unused; 
    volatile _per_run_data *_perRunData;  // reset for runs of the run loop 
    pthread_t _pthread;  // RunLoop对应的线程 
    uint32_t _winthread; 
    CFMutableSetRef _commonModes;  // 存储的是字符串,记录所有标记为common的mode 
    CFMutableSetRef _commonModeItems;  // 存储所有commonMode的item(source、timer、observer) 
    CFRunLoopModeRef _currentMode;  // 当前运行的mode 
    CFMutableSetRef _modes;  // 存储的是CFRunLoopModeRef 
    struct _block_item *_blocks_head;
    struct _block_item *_blocks_tail; 
    CFTypeRef _counterpart; 
};

 其核心方法是 __CFRunLoopRun(),伪代码如下:

int32_t __CFRunLoopRun()
{
    // 通知即将进入runloop
    __CFRunLoopDoObservers(KCFRunLoopEntry);
    
    do
    {
        // 通知将要处理timer和source
        __CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
        __CFRunLoopDoObservers(kCFRunLoopBeforeSources);
        
        // 处理非延迟的主线程调用
        __CFRunLoopDoBlocks();
        // 处理Source0事件
        __CFRunLoopDoSource0();
        
        if (sourceHandledThisLoop) {
            __CFRunLoopDoBlocks();
	     }
        // 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
        if (__Source0DidDispatchPortLastTime) {
            Boolean hasMsg = __CFRunLoopServiceMachPort();
            if (hasMsg) goto handle_msg;
        }
            
        // 通知 Observers: RunLoop 的线程即将进入休眠(sleep)。
        if (!sourceHandledThisLoop) {
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
        }
            
        // GCD dispatch main queue
        CheckIfExistMessagesInMainDispatchQueue();
        
        // 即将进入休眠
        __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
        
        // 等待内核mach_msg事件
        mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
        
        // 等待。。。
        
        // 从等待中醒来
        __CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
        
        // 处理因timer的唤醒
        if (wakeUpPort == timerPort)
            __CFRunLoopDoTimers();
        
        // 处理异步方法唤醒,如dispatch_async
        else if (wakeUpPort == mainDispatchQueuePort)
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
            
        // 处理Source1
        else
            __CFRunLoopDoSource1();
        
        // 再次确保是否有同步的方法需要调用
        __CFRunLoopDoBlocks();
        
    } while (!stop && !timeout);
    
    // 通知即将退出runloop
    __CFRunLoopDoObservers(CFRunLoopExit);
}

 类似于事件循环模型(Event Loop):

do {
    // 获取消息
    // 处理消息
} while (消息 != 退出)

 RunLoop 管理了需要处理的事件和消息,并通过上述函数 __CFRunLoopRun() 来执行事件循环模型的逻辑。当没有事件时,RunLoop 会进入休眠状态,当有事件发生时, RunLoop 会去找对应的 Handler 来处理事件。RunLoop 可以让线程在需要做事的时候忙起来,在不需要做事的时候进入休眠状态以节省系统资源。

  • RunLoop 和线程是一一对应的,二者以 key-value 的方式被保存在一个全局字典中;
  • 主线程的 RunLoop 会在初始化全局字典时被创建;
  • 子线程的 RunLoop 会在第一次获取的时候被创建,如果不获取的话就一直不会被创建;
  • RunLoop 会在线程销毁时被销毁。

RunLoop 的作用:

  • 保持程序的持续运行,避免线程被销毁;
  • 处理APP的各种事件(触摸、定时器等);
  • 节省cpu资源、提升程序的性能(该做事就做事,该休息就休息).

image.png

RunLoop Mode:

 每个 CFRunLoopMode 对象都有自己的名称,各自包含若干 source0、source1、timer、observer 和若干 port。因此事件都是由 Mode 管理,而 RunLoop 管理着不同的 Mode。RunLoop 某一时刻只能处理一个 Mode,即处理 Mode 相关的 Source、Timer、Observer。可以切换不同的 Model。

RunLoop Source:

 分为 Source、Observer、Timer 三种,统称为 ModelItem.

CFRunLoopSource:

 即事件输入源,分为 source0 和 source1 两大类型。source0 是 App 内部事件,由 App 自己管理的UIEvent、CFSocket 都是 source0。source1 可以监听系统端口、通过内核和其他线程通信、接收与分发系统事件,它能够主动唤醒 RunLoop。

CFRunLoopObserver:

 RunLoop 通过监控 Source 来决定有没有任务要做,除此之外,还可以用 RunLoop Observer 来监控 RunLoop 自身的状态。

 CFRunLoopObserver 是观察者,可以观察 RunLoop 的各种状态,相当于消息循环中的一个监听器,随时通知外部当前 RunLoop 的运行状态。可观察的状态如下:

/* Run Loop Observer Activities */ 
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { 
    kCFRunLoopEntry = (1UL << 0),          // 即将进入run loop 
    kCFRunLoopBeforeTimers = (1UL << 1),   // 即将处理timer 
    kCFRunLoopBeforeSources = (1UL << 2),  // 即将处理source 
    kCFRunLoopBeforeWaiting = (1UL << 5),  // 即将进入休眠 
    kCFRunLoopAfterWaiting = (1UL << 6),   // 被唤醒但是还没开始处理事件 
    kCFRunLoopExit = (1UL << 7),           // RunLoop 已经退出 
    kCFRunLoopAllActivities = 0x0FFFFFFFU  // 全部的活动
};

CFRunLoopTimer:

 CFRunLoopTimer是定时器,可以在设定的时间点抛出回调。

RunLoop 休眠:

 休眠机制依靠系统内核来完成,具体来说是依靠操作系统核心组件 Darwin 中的 Mach。当线程空闲时,RunLoop 停留在 __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy) ,而在这个函数内部,通过调用 mach_msg() 函数使得线程处于休眠状态。

本节参考文章:

 1. juejin.cn/post/684490…

 2. www.cnblogs.com/kenshincui/…

 3. juejin.cn/post/684490…

 4. www.jianshu.com/p/ac05ac842…

八、LLDB

 是一款集成于 Xcode 内部的调试器,常用调试命令如下。

help: 用于输出某个指令的帮助文档。

print: 输出变量或常量的值。如图所示,在 lldb 调试器中,可以使用 $n(n=0,1,2,...) 来引用对应的结果。

image.png

 print 命令是 ‘expression --’命令的等价形式,其中‘--’用于表示标识的结束以及输入的开始。

image.png

 可以通过 print/format 给 print 指定不同的输出格式,d 代表十进制格式, x 代表十六进制格式,o 代表八进制格式,t 代表二进制格式,a 代表输出内存地址。

image.png

expression: 用于改变某个变量的值。调试器与程序中的值都会被更改。

image.png

打印对象: po(print object)、expression -o --

image.png

变量的使用: 可以在 lldb 中使用自定义变量并使用之,但是必须以美元符号 $ 开头。

image.png

 当 lldb 无法确定类型时,需要给出相应的说明。

流程控制:

 continue: 会取消程序的暂停,允许程序正常执行 (要么一直执行下去,要么到达下一个断点)。在 LLDB 中,使用 process continue 命令可以达到同样的效果。

 step over: 会以黑盒的方式执行一行代码。如果所在这行代码是一个函数调用,那么就不会跳进这个函数,而是将这个函数执行完毕,然后继续。在 LLDB 中,可以使用 thread step-overnext达到同样的效果。

 step in: 如果确实想跳进一个函数调用来调试或者检查程序的执行情况,那就使用 step in,或者在LLDB中使用 thread step instep。注意,若当前行不是函数调用,则step over 和 step in 效果是一样的。

 step out: 如果不小心跳进一个函数,但实际上想跳过它,这种情况下,可以使用 step out。它会继续执行到下一个返回语句 (直到一个堆栈帧结束) 然后再次停止。

 thread return XXX: 在执行时该命令会把可选参数加载进返回寄存器里,然后立刻执行返回命令,跳出当前栈帧。这意味这函数剩余的部分不会被执行,从而给 ARC 的引用计数造成一些问题,或者会使函数内的清理部分失效。但是在函数的开头执行这个命令,是个非常好的隔离当前函数、指定返回值的方式。

九、数据存储方案

1.应用沙盒

 iOS本地化存储的数据保存在沙盒中, 并且每个应用的沙盒是相对独立的。每个应用的沙盒文件结构都是相同的。

 Documents: 用于保存程序生成的数据。iTunes同步设备时会备份该目录。一般用来存储需要持久化的数据。

 Library/Caches: 缓存,iTunes不会备份该目录。内存不足时会被清除,应用没有运行时可能会被清除。一般存储体积大、不需要备份的非重要数据。

 Library/Preference: iTunes会备份该目录,可以用来存储一些偏好设置。

 tmp: iTunes不会备份这个目录,用来保存临时数据,应用退出时、重启手机时、系统磁盘空间不足时会清除该目录下的数据。

 获取各个文件夹的路径:

// 此方法返回的是一个数组,由于 iOS 中 Documents 目录没有子目录,因此这个数组只有一个元素,所以可用 firstObject 或 lastObject 来获取 Documents 目录的路径 
// iOS 中主目录的全写形式是 /User/userName,第三个参数填 YES 就表示全写,填 NO 就写成‘‘~’’。
NSString *documentPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).lastObject;
// 得到 Document 目录下的 test.plist 文件的路径 
NSString *filePath = [documentPath stringByAppendingPathComponent:@"test.plist"];

// 获取 Library/Caches 目录路径 
NSString *path = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; 

//获取temp路径 
NSString *tmp= NSTemporaryDirectory();

2.Plist

 属性列表是一种 XML 格式的文件,拓展名为 plist. 如果对象是 NSString、NSDictionary、NSArray、NSData、NSNumber 等类型,就可使用 writeToFile:atomically: 方法直接将对象写入文件中。

NSMutableDictionary *dict = [NSMutableDictionary dictionary];
[dict setObject:@"张三" forKey:@"name"];
[dict setObject:@"155xxxxxxx" forKey:@"phone"];
[dict setObject:@"27" forKey:@"age"];

NSString *documentPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, **YES**).lastObject;
// 得到 Document 目录下的 test.plist 文件的路径
NSString *filePath = [documentPath stringByAppendingPathComponent:@"test.plist"];
// 将字典持久化到 Documents/test.plist 文件中
[dict writeToFile:filePath atomically:YES];

// 读取属性列表,恢复 NSDictionary 对象
NSDictionary *dict1 = [NSDictionary dictionaryWithContentsOfFile:filePath];
NSLog(@"name:%@", [dict1 objectForKey:@"name"]);
NSLog(@"phone:%@", [dict1 objectForKey:@"phone"]);
NSLog(@"age:%@", [dict1 objectForKey:@"age"]);

3.Preference

 iOS 提供了一套标准的解决方案来为应用加入偏好设置功能。每个应用都有个 NSUserDefaults 实例,通过它来存取偏好设置。

 通过 NSUserDefaults 设置数据时,不是立即写入,而是根据时间戳定时地把缓存中的数据写入本地磁盘。所以调用了set方法之后数据有可能还没有被写入磁盘,应用程序就终止了。为了避免出现以上问题,可以通过调用synchornize方法[defaults synchornize];强制写入偏好设置。

 NSUserDefaults 可以存储的数据类型包括:NSData、NSString、NSNumber、NSDate、NSArray、NSDictionary。如果要存储其他类型,则需要转换为前面的类型,才能用 NSUserDefaults 进行存储。

// 保存偏好设置
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setObject:@"张三" forKey:@"username"];
[defaults setFloat:18.0f forKey:@"text_size"];
[defaults setBool:**YES** forKey:@"auto_login"];

// 读取保存的偏好设置
NSUserDefaults *defaultss = [NSUserDefaults standardUserDefaults];
NSString *username = [defaultss stringForKey:@"username"];
float textSize = [defaultss floatForKey:@"text_size"];
BOOL autoLogin = [defaultss boolForKey:@"auto_login"];
NSLog(@"%@,%f,%@",username,textSize,autoLogin?@"YES":@"NO");

4.NSKeyedArchiver归档 / NSKeyedUnarchiver解档

 归档和解档会在写入、读出数据之前进行序列化、反序列化,数据的安全性相对高一些。

 只有遵守了 NSCoding 协议的对象才可以直接进行归档、解档,例如NSString、NSDictionary、NSArray、NSData、NSNumber等类型。若是自定义类型,必须使其遵守 NSCoding 协议。

// 对单个数据进行处理
NSError *error = nil;
NSData *archieveData = nil;
// 归档数据
archieveData = [NSKeyedArchiver archivedDataWithRootObject:@"test_file" requiringSecureCoding:YES error:&error];


// 解档数据
// 类型要与归档数据的类型相同,这样才能更好的承接数据
NSString *result = [NSKeyedUnarchiver unarchivedObjectOfClass:[NSString class] fromData:archieveData error:&error];
// 若上述函数返回值为 nil,则 error 中会保存出错信息
if (error) {
    NSLog(@"%@",error);
} else {
    NSLog(@"%@",result);
}
// 对多个数据进行处理
// 若有多个数据,可对每个数据都建立一个 key,然后用 value 保存数据
NSDictionary *dict1 = [NSDictionary dictionaryWithContentsOfFile:filePath];
NSLog(@"name:%@", [dict1 objectForKey:@"name"]);
NSLog(@"phone:%@", [dict1 objectForKey:@"phone"]);
NSLog(@"age:%@", [dict1 objectForKey:@"age"]);

NSError *error = nil;
NSData *archieveData = nil;
// 归档数据
archieveData = [NSKeyedArchiver archivedDataWithRootObject:[dict1 copy] requiringSecureCoding:YES error:&error];

// 解档数据
NSSet *set = [[NSSet alloc] initWithArray:@[[NSDictionary class]]];
// 类型要与归档数据的类型相同,这样才能更好的承接数据
NSDictionary *result = [NSKeyedUnarchiver unarchivedObjectOfClasses:set fromData:archieveData error:&error];
if (error) {
    NSLog(@"%@",error);
} else {
    NSLog(@"name:%@", [result objectForKey:@"name"]);
    NSLog(@"phone:%@", [result objectForKey:@"phone"]);
    NSLog(@"age:%@", [result objectForKey:@"age"]);
}

5.SQLite3

 一款嵌入式的轻量关系型文件数据库。

 使用步骤如下:

  • 首先添加库文件 libsqlite3.dylib,导入主头文件 #import<splite3.h>
  • 打开数据库;
  • 创建数据表;
  • 对数据进行增、删、改、查操作;
  • 关闭数据库。

 注意事项:

  • SQLite 不区分大小写,但也有需要注意的地方,例如GLOB 和 glob 具有不同作用。
  • SQLite3 基本数据类型有 text(文本字符串)、整型数据、浮点型数据、null、blob(二进制数据)等。
  • SQLite3 是无类型的,例如即使声明了 integer 类型,但仍能存储字符串文本。因此在创建的时候可以不声明字段的类型,不过为了良好的代码规范,建议加上数据类型。

相关操作语句待补充。

6.FMDB

 FMDB 是 iOS 平台的 SQLite 数据库框架,使用 OC 封装了 SQLite 的 C 语言 API。使用起来更加轻量级和灵活。并且 FMDB 提供了多线程安全的数据库操作方法,能有效地防止数据混乱。

 三个主要的类:

  • FMDatabase:一个对象就代表一个单独的 SQLite 数据库,用于执行 SQL 语句。
  • FMResultSet:使用 FMDatabase 执行查询后的结果集。
  • FMDatabaseQueue:用于在多线程中执行多个查询或更新,它是线程安全的。

相关操作语句待补充。

7.Core Data

 相较于SQLiteCore Data做了更进一步的封装,可以解决数据在持久化层和代码层的一一对应关系。也就是说,当处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要实现额外的代码。这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM)。

 对Core Data进行初始化,实现本地数据的保存。 需要用到的类有三个:

  • NSManagedObjectModel: 数据模型的结构信息;
  • NSPersistentStoreCoordinator: 数据持久层和对象模型协调器;
  • NSManagedObjectContext: 对象的上下文 managedObject 模型。

本节参考文章

 1. iOS 数据存储方法基本使用

 2. iOS 数据存储

 3. 升级 Xcode 11 踩坑归档解档

 4. iOS 的 Core Data 技术详解

 5. iOS-CoreData 详解与使用

十、代码检测工具

 程序静态分析(Program Static Analysis)是指在不运行代码的情况下,通过词法分析、语法分析、控制流、数据流分析等技术对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标的一种代码分析技术。

1.Coverity

 将布尔可满足性验证技术应用于源代码分析引擎,分析引擎利用软件 DNA 图谱技术和 meta-compilation 技术,综合分析源代码、编译构建系统和操作系统等可能使软件产生缺陷的地方。

2.Clang

 Clang 作为 LLVM 编译器框架的前端,最主要的任务是词法分析、语法分析,中间代码生成。源代码通过clang 语法分析后,生成了语法分析树(AST),进而对 AST 进行分析。

3.Infer

 Infer 是 Facebook 开源的用来执行增量分析的一款静态分析工具,目前能检测出空指针访问、资源泄露以及内存泄露、循环引用、过早的 nil 操作,可对 C、Java 和 Objective-C 代码进行检测。因为Infer默认是增量分析,只会分析变动的代码,所以如果想整体分析,需要先 clean 项目再进行分析。

4.OCLint

 OCLint 是基于 Clang Tooling 开发的、针对 C、C++、Objective-C 代码的静态扫描分析工具,可以和 Xcode、Xcodebuild、xctool 等集成,使用命令行方式生成分析报告。可自定义规则。

5.SwiftLint

 SwiftLint 是一个用于强制检查 Swift 代码风格和规定的一个工具。它的实现是 Hook 了 Clang 和 SourceKit 从而能够使用 AST 来表示源代码文件的更多精确结果。SourceKit 包含在 Swift 项目的主仓库,它是一套工具集,支持 Swift 的大多数源代码操作特性:源代码解析、语法突出显示、排版、自动完成、跨语言头生成等工作。可自定义规则。

本节参考文章

  1. IOS静态代码扫描--分析与总结
  2. 如何通过静态分析提高iOS代码质量

十一、MVC、MVP、MVVM

1.MVC

image.png
  • 视图(View):用户界面,用于将应用的数据展示给用户。
  • 控制器(Controller):业务逻辑,用于对用户输入做出响应(更新 model、view)。
  • 模型(Model):保存应用的数据,负责数据持久化存储和读取等工作。

 三者之间的通信方式如下:

 1. View 传送用户交互动作到 Controller;

 2. Controller 完成业务逻辑后,要求 Model 改变状态。有时 Controller 会直接更新 View;

 3. Model 将新的数据发送到 View,用户得到反馈。

 Apple Cocoa MVC:

image.png

 View 与 Model 之间不能进行直接通信,需通过 Controller 进行间接通信。

2.MVP

image.png

 在 MVC 中,View 与 Controller 很难做到完全的分离,二者经常耦合在一起。而在 MVP 中,使用 Presenter 替代 Controller 来完成 View 与 Model 的交互,使得 View/ViewController 与 Model 完全解耦。如此一来,Model 便能专注于应用数据的处理,而 View 便能专注于 UI 视图的展示。

3.MVVM

image.png

 MVVM 类似于 MVP,主要区别是通过 KVO 等机制将 ViewModel 绑定到了 View 上,一旦前者发生变化,后者会自动感知,无需主动通知。

 View 类持有 ViewModel 对象,ViewModel 类持有 Model 对象。

 ViewModel 对 View 一无所知,但它提供了View 可能需要的所有数据。

 MVVM 可以降低 ViewController 的复杂性,并使得表示逻辑都集中于 ViewModel,如此便易于对业务逻辑进行测试。

本节参考文章

  1. iOS架构浅谈从 MVC、MVP 到 MVVM
  2. MVVM 介绍
  3. MVC
  4. Apple MVC

十二、iOS 证书签名

1.基本概念

 1)App ID:用于标识一个或一组 app。

  • Explicit App ID:是app的唯一标识符,由苹果为开发者创建的team id和app的bundle id组成。每个app都会有且仅有一个明确的Explicit App ID

  • Wildcard App ID:即通配符App ID,用于标识一组app,以*结束,如 com.company.* 标识以com.company 开头的所有应用程序。

 2)证书(Certificate)

 iOS 证书用于为应用程序签名,经过签名之后,应用的来源才是可信的,并且代码是完整、未修改的。证书后缀是 .cer

 数字证书一般由 CA 认证中心颁发,用于验证通信实体身份信息。最简单的证书包含一个公开密钥、名称以及证书授权中心的数字签名。数字证书具有时效性:只在特定的时间段内有效。

 3)Device

Devices中包含了苹果开发者账号中所有可用于开发和测试的设备,每台设备使用UDID来唯一标识。

 4)Provisioning Profile(描述文件)

 描述文件包含了App IDCertificateDeviceEntitlements等,其后缀为.mobileprovisionEntitlements(权限信息)指明了 app 使用的苹果服务,如 iCloud 权限、推送、苹果内购等。

2.iOS 证书签名

 1)在向 Apple 服务器申请证书之前,Xcode 会向钥匙串申请一个 CSR(Certificate Signing Request)文件,同时生成一对非对称加密密钥,设其为公钥 M、私钥 M。而后将公钥 M 放入 CSR 文件中。此私钥 M 仅在生成私钥的 Mac 电脑上存在。若其他 Mac 也需使用该私钥,则需要在钥匙串中将私钥导出为 .p12 文件,而后传递给其他电脑使用。

 2)Xcode 将 CSR 文件发送给 Apple 服务器,用于申请证书。

 3)Apple 服务器自己也有一对非对称加密密钥(公钥 A、私钥 A),在收到 CSR 文件后,会用自己的私钥 A 对 CSR 文件中的公钥 M 进行签名,进而生成相应的证书。接着会对 App IDCertificateDeviceEntitlements等文件使用私钥 A 进行签名,生成描述文件。之后将描述文件发送给 Xcode。

 4)iOS 项目在编译完成后会生成 .app 文件。Xcode 会先用私钥 M 对 .app 文件进行签名加密,而后将加密后的 .app 文件和描述文件一起压缩成安装包 .ipa 文件。

 5)iPhone 在安装 app 之前,会对安装包进行验证。首先使用公钥 A 对描述文件进行解密验证。验证通过则说明描述文件的数据是经过 Apple 授权认证的。

 6)当步骤 5 验证通过后,会使用公钥 A 对描述文件中的证书文件进行解密验证,若验证通过则说明证书是可信的,亦即说明证书里包含的公钥 M 是可信的。

 7)当步骤 6 验证通过后,iPhone 便会核对描述文件中的 App IDCertificateDeviceEntitlements信息。当核对通过后,便会使用公钥 M 对安装包中的 .app 文件进行解密,而后安装应用程序。

本节参考文章

  1. iOS 证书签名原理分析
  2. 深入理解 iOS 签名原理

十三、静态库与动态库

1.概念

 静态库:在链接阶段,会将汇编生成的目标文件与引用到的库一起打包到可执行文件中。若库被多次使用就会有多份拷贝。

 动态库:在程序编译时并不会被链接到目标代码中,程序运行时由系统动态加载到内存,供程序调用,系统只加载一次,多个程序共用,节省内存。

2.特点

静态库:

  • 函数库的链接是在编译时期完成的。
  • 程序在运行时与函数库再无瓜葛,移植方便。
  • 会造成空间资源的浪费,因为若库被多次使用,便会被多次拷贝。
  • 由于静态库在编译期间便被载入可执行文件,因此文件体积会较大。

动态库:

  • 动态库把对一些库函数的链接载入推迟到程序运行的时期。
  • 可以实现进程之间的资源共享。
  • 动态库链接的时候,只是保留接口。动态库与程序代码之间相互独立,这样就可以提高代码的可复用度、降低程序的耦合度。

3.iOS

 静态库通常为 .a 文件、.framework文件,动态库通常为 .dylib 文件、.framework 文件。

 .framework 文件是一种资源打包方式,可以将代码文件、头文件、资源文件、说明文档等集中在一起,方便开发者使用。系统提供给的 .framework 是动态库。

 无论是 .a 静态库还是 .framework 静态库,最终需要的都是二进制文件、.h 文件、其它资源文件的综合体。.a 文件本身仅是二进制文件,需要手动加上 .h 文件和其它文件才能使用。而 .framework 文件本身已经包含了 .h 文件和其它文件,因此可以直接使用。

使用静态库的好处:

  • 编译后的执行程序不需要外部的函数库支持,因为所有使用的函数都已被编译进去了;
  • 节省程序的启动时间。

使用动态库的好处:

  • 使用动态库,可以将最终可执行文件体积缩小;
  • 使用动态库,多个应用程序可以共享内存中的同一份库文件,能够节省资源;
  • 使用动态库,可以在不重新编译连接可执行程序的前提下,仅通过更新动态库文件达到更新应用程序的目的。

本节参考文章

  1. 动态库与静态库的区别
  2. 细说 iOS 静态库与动态库
  3. iOS 动态库-静态库
  4. 浅析静态库与动态库的区别

十四、面向对象编程与面向协议编程的优点

1.面向对象编程的优点

 1)封装特性能够提高类的安全性,减少编程过程中代码出错的风险。

 2)继承特性实现了代码的复用。

 3)抽象特性能够让程序的设计和实现分离开来。

 4)多态提高了程序的可扩展性。父类提供了相应接口,子类根据自身需求编写具体实现。

 5)使得代码模块化,易于维护与修改。

2.面向协议编程的优点

 1)在面向对象编程中,如果某些功能是不同继承链均需要的,但将此功能抽出来单独设计一个类,代价又太大的话,那么就不得不在每个类中重复一遍相应的代码,这样会使得代码很冗长。因此就这一点来说,特性的组合要比继承更贴近本质。

  而在面向协议编程中,可以很容易的通过'协议+扩展'实现一个功能。使用者可以像搭积木一样随意组合不同的协议,进而实现特性的不同组合。

 2)在面向对象编程中,如果想在原来功能的基础上开发新的功能,那么就必须继承原先的类,并在子类中实现特定的需求。但是随着继承层次的增加,代码的复杂度也会随之上升,从而导致问题越来越难于排查。

  而在面向协议编程中,如果想实现新的功能,那么只需要实现相应的协议,然后让类遵循相应协议即可。若某个功能出现了问题,可以直接到对应的协议中排查。

 3)Objective-C 具有强大的 Runtime,但这一机制也使得编译器只有在运行时才能确定要调用的方法是否存在。若编译器找不到相应函数,会抛出异常 “unknown selector sent to instance”。虽然有其他方法(如结合使用performSeletor:respondsToSelector:)可以在运行时妥善处理这个问题从而使得程序不至于崩溃,但是依然是在运行时才能解决这个问题。

  通过使用协议,可以在编译时发现上述问题。可以将要调用的方法放入协议中,若调用该方法的对象不遵循相应协议,则编译器会在编译期报错。

本节参考文章

  1. Swift面向协议编程总结
  2. 面向协议编程初探

十五、App Extension

1. 定义

  App Extension 可以让开发者们将自定义的功能和内容拓展到应用程序之外,并在用户与其他应用程序或系统交互时将拓展内容提供给用户。每一个 extension 都是一个独立的二进制文件,它独立于发布它的应用程序。必须使用一个 app 去包含并发布相应的 extension。

  App Extension 和主 app (containing app) 之间没有直接关系,二者是两个独立的程序,最直接的联系就是 Extension 会跟随主 app 一起安装,主 app 卸载时,二者一起被卸载。代码不能相互调用、存储空间也不能相互访问。

2. 概念

  • app extension : 扩展特定 app 的功能,并且依赖于特定的 app。
  • containing app:一个包含一个或多个 extension 的 app。
  • host app : 能够调起 extension 的 app,比如:在 Safari app 里,可以将网页分享到微信,那么 Safari 就是 host app。

  宿主应用程序定义了提供给扩展的上下文环境,并在响应用户请求时启动扩展。

  扩展的生命周期和包含该扩展的 containing app 的生命周期是相互独立的,准确地说它们是两个独立的进程。

image.png

3. App Extension 和 App 的通信

3.1 简单交互

image.png
  • app extension 和 containing app 不能直接交互。containing app 可能还没有开始运行,然而它包含的app extension 已经在运行了,例如,一个天气的 app,当你还没有打开它时,就可以在 Today Widget 中看到今天天气的信息。

3.2 具体交互

  任意一个 app extension 和它的 containing app 能够在一个私有的 shared resources 中分享数据。下图展示了containing app , app extension 和 host app 之间完整的交互方式。

image.png

4. App Extension 的种类

  分为包含 UI、不包含 UI 两大类。前者可以弹出页面,后者在用户点击对应的 App 图标时可以监听到 Handler 事件。

  • Audio Unit Extension,音频处理应用扩展,Host App 可以使用这个扩展来处理已经存在的音频文件。
  • Authentication Services Extension,应用在企业单一登录上。例如王者荣耀等一系列腾讯游戏的微信授权登录。在很多情况下,游戏中登录不需要跳转到微信 App 内,这就是通过应用扩展来实现的。
  • AutoFill Credential Provider Extension,和 iOS 12 之后的自动密码填写有关。
  • Broadcast Upload Extension,录屏监听应用扩展,这个扩展是与ReplayKit 配合使用的,录制端的 App 使用 ReplayKit 进行录制,监听端 App 制作 Broadcast Upload Extension 应用扩展用来获取录制到的数据。
  • Broadcast Setup UI Extension,用于在开启录制时提前与用户进行 UI 上的交互,比如登录账号等。主要用于游戏录屏功能。
  • Call Directory Extension,配合 CallKit 进行来电识别和来电阻止,类似于钉钉电话里面的普通电话,会显示钉钉的名称。
  • ClassKit Context Provider Extension,配合 CallKit 使用,可以使应用内的通话,具有和电话一样的界面和电话效果,例如钉钉电话。
  • Content Blocker Extension,配合 Safari 使用,可以屏蔽掉 Safari 页面内的广告。
  • Custom Keyboard Extension,可以自定义想要的系统级键盘。
  • File Provider Extension && File Provider UI Extension,用于向其他应用共享储存在自己 App 里面的文档资源,File Provider UI Extension 用于定义共享资源文档时的UI界面。
  • iMessage Extension,用于在消息 App 中定义其他可发送的表情。
  • Intent Extension && Intent UI Extension 主要用于定义 Siri 或者捷径 App 发送的意图,意图会发送给这个应用扩展程序来处理。Intent UI Extension 是来定义意图处理时需要显示的 UI 界面,一般是可选的。
  • Message Filter Extensio,短信拦截应用扩展,可以用来判断是否拦截短信。
  • Network Extension,主要用于创建 VPN 代理服务和 DNS 代理服务,因为这两个服务需要脱离原来的 App 运行,因此需要通过应用扩展来实现。
  • Notification Content Extension,用于定义消息通知栏目下拉之后的内容界面,例如收到微信的消息通知时,下拉通知,就会显示输入框可以直接回复消息,这个就是此 Extension 实现的功能。
  • Notification Service Extension,用来丰富自定义框,在收到推送消息到弹出推送消息之间,系统会调用创建的应用扩展来优化你的系统弹框,例如百度地图里面的推送,会显示推送的图片等。
  • Photo Edit Extension,用来进行图像编辑的应用扩展,在系统相册中编辑图片时,可以选择对应的应用扩展来对图片进行处理。
  • Quick Look Preview Extension,用于提供给其他 App 展示快速浏览的界面的应用扩展。
  • Share Extension,分享应用扩展,iOS 的系统 App 或者第三方 App 都可以调用系统的分享弹窗。如果有这个应用扩展,则可以在这里选择你的 App 进行分享。
  • SpotLight Index Extension,搜索类应用扩展,可以使用 iphone 的下拉搜索,搜索到你应用内的数据。例如可以在搜索中搜索到百度 App 中的相关新闻。
  • Sticker Pack Extension,贴纸扩展,这个是在 iMessage 中使用的应用扩展,可以定义自己的贴纸到iMessage 中。
  • Thumbnail Extension,缩略图扩展,在其他 App 使用 Quick Look 的框架展示文件的时候,可以使用这个应用扩展来显示文件缩略图。
  • Today Extension,iOS 手机最左侧屏幕的小工具应用扩展。
  • UnWant Communication Extension,短信/来电报告扩展。用于接收用户的短信和来电内容,但是需要用户在设置 -> 电话 -> 短信/来电报告 里面打开才可以使用。

本节参考文章

  1. iOS Extension 拓展--从开发到发布全流程
  2. App extension 总结
  3. iOS App Extension 学习笔记(二)---- Extension的种类及功能

十六、动画

1. UIView 动画

  • 通过普通方式实现:在 iOS 13 之后已不再使用该方式。
// "Use the block-based animation API instead", ios(2.0, 13.0)
// 开始动画
+ (void)beginAnimations:(nullable NSString *)animationID context:(nullable void *)context;

// 结束动画
+ (void)commitAnimations
  • 通过 block 实现:

  以下各种类型动画,均仅例举了其中一个方法。

// 可以设置延时时间和过渡效果的动画,此为其中一种通过 block 实现的动画
+ (void)animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^ __nullable)(BOOL finished))completion;

// 具有弹簧效果的动画
+ (void)animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay usingSpringWithDamping:(CGFloat)dampingRatio initialSpringVelocity:(CGFloat)velocity options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^ __nullable)(BOOL finished))completion;

// 关键帧动画
+ (void)animateKeyframesWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewKeyframeAnimationOptions)options animations:(void (^)(void))animations completion:(void (^ __nullable)(BOOL finished))completion; 

// 转场动画
+ (void)transitionFromView:(UIView *)fromView toView:(UIView *)toView duration:(NSTimeInterval)duration options:(UIViewAnimationOptions)options completion:(void (^ __nullable)(BOOL finished));

2.Core Animation

  • layer 与 view: UIView 之所以能显示在屏幕上,完全是因为与其对应的 layer。

  在创建 UIView 对象时,系统会自动创建一个对应的 layer(即CALayer对象),通过 UIView 的layer 属性可以进行访问。当 UIView 需要被显示到屏幕上时,会调用 drawRect: 方法进行绘图,并且会将所有内容绘制在自己的层上,绘图完毕后,系统会将层拷贝到屏幕上,于是就完成了 UIView 的显示。

  因此,view 并不负责显示,显示功能由 layer 负责。view 负责处理用户点击事件,而 layer 不参与此项内容。

  • CALayer 对象有两个比较重要的属性:position、anchorPoint。

  position 可以用来设置 CALayer 在父层中的位置,以父层的左上角为坐标原点(0, 0)。

  anchorPoint 被称为"定位点",它决定着CALayer身上的哪个点会在 position 属性所指的位置。它的 x、y 的取值范围都是 0~1(0 代表起点,1 代表终点),默认值为(0.5, 0.5)。

  • Core Animation 的动画执行过程都是在后台,不会阻塞主线程。Core Animation 直接作用在 CALayer 上,并非 UIView。
  • Core Animation 的动画过程只是修改了显示效果,并没有改变控件的实际位置。也就是说在执行动画的过程中,控件是不能跟用户进行交互的。但是在控件的起始位置是可以进行交互的。
CAAnimation:所有动画对象的父类,负责控制动画的持续时间和速度,是个抽象类,不能直接使用,应该使用其具体的子类。

CAPropertyAnimation:是 CAAnimation 的子类,也是个抽象类。通过指定 CALayer 的一个名为 keyPath(例如 'position') 的属性,并对 CALayer 的这个属性的值进行修改,来达到相应的动画效果。
    CABasicAnimation:基本动画,是 CAPropertyAnimation 的子类。
    CAKeyframeAnimation:关键帧动画,是 CAPropertyAnimation 的子类。CABasicAnimation 可被看做是只有 2 个关键帧的 CAKeyframeAnimationCATransition:是 CAAnimation 的子类,用于做转场动画,能够为层提供移出屏幕和移入屏幕的动画效果(提供了多种不同的效果)。
              UINavigationController 便是通过 CATransition 实现了将控制器的视图推入屏幕的动画效果。

CAAnimationGroup:动画组,是 CAAnimation 的子类,可以用于保存一组动画对象,将 CAAnimationGroup 对象加入层后,组中所有动画对象可以同时并发运行。
                  默认情况下,一组动画对象是同时运行的,但也可以通过设置动画对象的 beginTime 属性来更改动画的开始时间。
                  
CASpringAnimation:是 CAAnimation 的子类,这个动画的效果就是像弹簧一样摆动。 

本节参考文章

  1. iOS动画,原理与实操(详细)
  2. iOS动画全面解析
  3. Core Animation Basics
  4. CALayer

十七、XCTest

当我们创建 iOS 工程时,可以勾选创建Include UITest选项,之后在工程中会生成'ProjectNameUITests' 文件,可以在此文件中编写测试代码进行 UI 测试。该框架提供了许多功能,此处列举较为常用的一些功能。

1. 获取当前页面系统组件

XCTest 框架提供了 XCUIElementTypeQueryProvider 协议,我们可以借助该协议来获取当前页面的系统组件。

public protocol XCUIElementTypeQueryProvider {

    @NSCopying var touchBars: XCUIElementQuery { get }

    @NSCopying var groups: XCUIElementQuery { get }  

    @NSCopying var windows: XCUIElementQuery { get }

    @NSCopying var sheets: XCUIElementQuery { get }

    @NSCopying var drawers: XCUIElementQuery { get }

    @NSCopying var alerts: XCUIElementQuery { get }

    @NSCopying var buttons: XCUIElementQuery { get }
    
    // some other XCUIElementQuery
    
    var firstMatch: XCUIElement { get }
};

例如:

//模拟点击某个按钮
app.buttons["登录"].tap()

//模拟输入文本
app.textFields["手机号"].tap()    //要先聚焦文本框,才能继续输入
app.textFields["手机号"].typeText("13038865629")

//模拟切换 tab
app.tabBars.buttons["首页"].tap()

//判断元素是否存在
if app.buttons["确定"].exists {
//do something
}

//使用谓词来匹配元素
let addButton = app.tables.cells.matching(NSPredicate(format: "label CONTAINS %@", "铜")).element(boundBy: 0)
addButton.tap()

2. 归类不同的测试行为

通过创建 activity 来将不同的测试行为进行归类,可以优化测试报告以及测试代码的可读性:

extension XCTContext {
    /// Create and run a new activity with provided name and block.
    public class func runActivity<Result>(named name: String, block: (XCTActivity) throws -> Result) rethrows -> Result
}

例如:

XCTContext.runActivity(named: "测试直播间内 - 子Tab切换", block: { _ in
    app.otherElements["策略"].tap()
    app.otherElements["提问"].tap()
    app.otherElements["介绍"].tap()
    app.otherElements["节目"].tap()
    app.otherElements["直播"].tap()
})

相应的测试报告日志:

image.png

3. 创建屏幕截图

XCTContext.runActivity(named:"Save the Path of Operations") {  activity  in
    let screenshot = XCUIScreen.main.screenshot()
    // Since xcparse can't parse PNG-formatted screenshots, and take the storage into account, the quality of the screenshots is set to.low
    let fullScreenshotAttachment = XCTAttachment.init(image: screenshot.image, quality: .low)
    fullScreenshotAttachment.name = "ScreenShot_\(self.screenShotCount).jpeg"
    fullScreenshotAttachment.lifetime = .keepAlways
    activity.add(fullScreenshotAttachment)
 }

每次执行测试,都会生成一份对应的 XCResult 文件(保存在工程的 Derived Data 路径)。上述截图会被存储在 XCResult 中,XCode 提供了 GUI 来查看 XCResult,只要双击 XCResult 文件即可自动跳转到 GUI 界面。如图所示。

image.png

或者也可点击上图左侧的 Test xxx ,也会跳转到当前的 GUI 页面,以查看对应的测试结果报告。

如果想导出截图,可使用官方提供的xcrun xcresulttool命令来导出结果。但是此工具操作较为复杂,可使用开源工具 xcparse 来导出图片。解析结果如下图: image.png

4. 打印当前页面的 UI 元素结构

首先在测试代码中加断点,当程序执行到断点处暂停时,我们可以操作 app 跳转到目标页面,此时通过如下 lldb 命令即可打印出当前页面的 UI 元素结构。

po app

如果查看不方便,可以将结果复制到其他文本编辑器,效果如下图:

image.png

5.测试元素的语法

XCUIElement:
继承 NSObject,遵循协议XCUIElementAttributes(描述 UI 组件的属性), XCUIElementTypeQueryProvider(提供了各种不同类型的 UI 组件的查询入口,使用类似 key-value 的机制得到 XCUIElement 的实例)。
可用于表示系统的各种 UI 元素。
取某种类型的元素以及它的子类集合:
descendantsMatchingType(type:XCUIElementType)->XCUIElementQuery:
取某种类型的元素集合,不包含它的子类:
childrenMatchingType(type:XCUIElementType)->XCUIElementQuery:

这两个方法的区别在于,当仅使用系统的 UI 组件时,用 childrenMatchingType 即可,如果还希望查询自定义的继承自系统组件的 UI 组件,就要用 descendantsMatchingType.

XCUIApplication:
继承XCUIElement,这个类掌管应用程序的生命周期,还可用于在启动的时候设置一些启动参数。里面包含两个主要方法
launch(): 启动程序。
terminate(): 终止程序。

6.自动化

通过 shell 脚本使用模拟器来执行 UITest

#启动指定的模拟器
xcrun simctl boot  0A92C038-AA51-44C7-8E7E-29A8EBF8C96E 
open "/Applications/Xcode14.app/Contents/Developer/Applications/Simulator.app/"
#将 app 安装到模拟器上
xcrun simctl install booted /path to projectName.app
#如果使用了 Pod,就改为 -workspace
#scheme 要选择 xxxUITest
xcodebuild test -project $projectPath -scheme $schemeName -derivedDataPath '$outPath' -destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.1' -quiet
#关闭模拟器
xcrun simctl shutdown 0A92C038-AA51-44C7-8E7E-29A8EBF8C96E

模拟器的信息必须准确,可通过xcrun xctrace list devices 命令来获取当前可使用的模拟器信息。

image.png

7.从 xcodebuild 传递自定义参数

通过 Xcode 环境变量来实现:

image.png
xcodebuild test -project $projectPath -scheme $schemeName -derivedDataPath '$outPath' -destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.1' TimeForTest='${TimeForTest}' -quiet

在代码中获取传递的参数:

let timeForTestString:String? = ProcessInfo.processInfo.environment["TimeForTest"];

本节参考文章

  1. XCTest测试指南
  2. iOS-UI自动化-Apple-XCUITest技术
  3. XCUITest-在 iOS 测试中的妙用 (番外篇)
  4. iOS XCTest实战—解决国际化开发测试痛点(上)
  5. UI测试
  6. xcode进行xcuitest的时候如何从xcodebuild传递自定义参数到程序中

十八、OC 与 Swift 混编

1. ProjectName-Bridging-Header.hProjectName-Swift.h

1)ProjectName-Bridging-Header.h

在 OC 工程里创建一个 Swift 类会跳出如下弹框:

点击Create Bridging Header按钮会生成桥接文件ProjectName-Bridging-Header.h。同时会在 Build Settings 里添加 Swift Complier 配置:

如果没有点击Create Bridging Header按钮,而是点击了Don't create,那么将不会创建桥接文件,但是Build Settings 里仍会添加 Swift Complier 配置项,只是该配置项里没有 Bridging Header,这时需要开发者自行创建桥接文件,并配置好路径。

2)ProjectName-Swift.h

若在 Swfit 文件中,加上 @objc修饰符,则在编译的时候会自动为 Swift 文件生成一个ProjectName-Swift.h文件,在文件中,相应的 Swift 代码会被转译为 OC 代码。但是主工程里必须存在 swift 文件!

2. OC 调用 Swift 接口

1)在 Swift 类里的接口前加上@objc修饰符,另外还要注意访问权限。

@objc class Dog: NSObject {    
    @objc let legNumber = 4

    @objc func eat() {
        print("The dog is eating")
    }
}

2)在 OC 类中导入ProjectName-Swift.h

#import "MainProject-Swift.h"

3)在 OC 类里引用 Swift 类。

#import "MainProject-Swift.h"

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    Dog *dog = [[Dog alloc] init];
    [dog eat];
}
@end

3. Swift 调用 OC 接口

1)在ProjectName-Bridging-Header.h桥接文件里引入需要被 Swift 调用的 OC 类。

#import "Cat.h"

2)在 Swift 文件中直接使用ProjectName-Bridging-Header.h里引入的类。

var name: Dog = Dog.init()

4. OC 主工程里调用 Swift Pod

1)首先要确保 OC 主工程处于混编模式,最方便的方法是新建一个 swift 文件,自动创建桥接文件。

2)后续操作如 2.OC 调用 Swift 接口 所述。

5. Swift 主工程里调用 OC Pod

1)首先要确保 Swift 主工程处于混编模式。

2)后续操作如 3.Swift 调用 OC 接口所述。

6. OC Pod 引用 Swift Pod

只需引入相关的接口文件即可:ProjectName-Swift.h
需要注意的是,在 podspec 文件里要添加 dependency,否则会无法引用相关 pod。

对于 OC 不支持的 Swift 的特性,例如有默认值的参数在调用时可省略、Struct 等,即使使用 @objc,在 OC 中也不能使用。

public init(view: UIView,
            tapUIApplication: Bool = true,
            configuration: Configuration = Configuration(),
            bezierPathDrawer: @escaping BezierPathDrawer = MonkeyPawDrawer.monkeyHandPath) {
   //do something         
}

// 可以使用如下函数进行包装,而后 OC 调用当前函数
@objc public convenience init(view: UIView,
                              tapUIApplication: Bool = true) {
        self.init(view: view, tapUIApplication: tapUIApplication)
}

7. Swift Pod 引用 OC Pod

需要使用 modular 的编译方式,在 podfile 里把use_frameworks!改为use_modular_headers!,这样就可以使用import 方式引用 pod。

本节参考文章

  1. Swift与OC混编
  2. Swift和Objective-C混编在有赞移动的实践