iOS 使用NSMethodSignature和 NSInvocation进行 method 或 block的调用

From: https://juejin.im/post/5a30c7c151882503eb4b44e2

这篇博文是我的另一篇 Aspects源码剖析中的一部分,考虑到这部分内容相对独立,单独成篇以便查询。

使用NSMethodSignatureNSInvocation 不仅可以完成对method的调用,也可以完成block的调用。在Aspect中,正是运用NSMethodSignature,NSInvocation 实现了对block的统一处理。这篇博文将演示NSMethodSignatureNSInvocation的使用方法及如何使用他们执行method 或 block。

##对象调用method代码示例 一个实例对象可以通过三种方式调用其方法。

- (void)test{

//type1
    [self printStr1:@"hello world 1"];

//type2
    [self performSelector:@selector(printStr1:) withObject:@"hello world 2"];

//type3
    //获取方法签名
    NSMethodSignature *sigOfPrintStr = [self methodSignatureForSelector:@selector(printStr1:)];

    //获取方法签名对应的invocation
    NSInvocation *invocationOfPrintStr = [NSInvocation invocationWithMethodSignature:sigOfPrintStr];

    /**
    设置消息接受者,与[invocationOfPrintStr setArgument:(__bridge void * _Nonnull)(self) atIndex:0]等价
    */
    [invocationOfPrintStr setTarget:self];

    /**设置要执行的selector。与[invocationOfPrintStr setArgument:@selector(printStr1:) atIndex:1] 等价*/
    [invocationOfPrintStr setSelector:@selector(printStr1:)];

    //设置参数 
    NSString *str = @"hello world 3";
    [invocationOfPrintStr setArgument:&str atIndex:2];

    //开始执行
    [invocationOfPrintStr invoke];
}

- (void)printStr1:(NSString*)str{
    NSLog(@"printStr1  %@",str);
}
复制代码

在调用test方法时,会分别输出:

2017-01-11 15:20:21.642 AspectTest[2997:146594] printStr1  hello world 1
2017-01-11 15:20:21.643 AspectTest[2997:146594] printStr1  hello world 2
2017-01-11 15:20:21.643 AspectTest[2997:146594] printStr1  hello world 3
复制代码

type1和type2是我们常用的,这里不在赘述,我们来说说type3。 NSMethodSignatureNSInvocationFoundation框架为我们提供的一种调用方法的方式,经常用于消息转发。

##NSMethodSignature概述

NSMethodSignature用于描述method的类型信息:返回值类型,及每个参数的类型。 可以通过下面的方式进行创建:

@interface NSObject 
//获取实例方法的签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
//获取类方法的签名
+ (NSMethodSignature *)instanceMethodSignatureForSelector:(SEL)aSelector;
@end

-------------
//使用ObjCTypes创建方法签名
@interface NSMethodSignature
+ (nullable NSMethodSignature *)signatureWithObjCTypes:(const char *)types;
@end
复制代码

使用NSObject的实例方法和类方法创建NSMethodSignature很简单,不说了。咱撩一撩signatureWithObjCTypes。 在OC中,每一种数据类型可以通过一个字符编码来表示(Objective-C type encodings)。例如字符‘@’代表一个object, ‘i’代表int。 那么,由这些字符组成的字符数组就可以表示方法类型了。举个例子:上面提到的printStr1:对应的ObjCTypes 为 v@:@。

  • ’v‘ : void类型,第一个字符代表返回值类型
  • ’@‘ : 一个id类型的对象,第一个参数类型
  • ’:‘ : 对应SEL,第二个参数类型
  • ’@‘ : 一个id类型的对象,第三个参数类型,也就是- (void)printStr1:(NSString*)str中的str。

printStr1:本来是一个参数,ObjCTypes怎么成了三个参数?要理解这个还必须理解OC中的消息机制。一个method对应的结构体如下,ObjCTypes中的参数其实与IMP method_imp 函数指针指向的函数的参数相一致。相关内容有很多,不了解的可以参考这篇文章[方法与消息][3]。

[3]: https://link.juejin.im?target=http%3A%2F%2Fwww.cocoachina.com%2Fios%2F20141106%2F10150.html

typedef struct objc_method Method;
struct objc_method {
SEL method_name OBJC2_UNAVAILABLE; // 方法名
char
method_types OBJC2_UNAVAILABLE;
IMP method_imp OBJC2_UNAVAILABLE; // 方法实现
}
复制代码

##NSInvocation概述

就像示例代码所示,我们可以通过+ (NSInvocation *)invocationWithMethodSignature:(NSMethodSignature *)sig;创建出NSInvocation对象。接下来你设置各个参数信息, 然后调用invoke进行调用。执行结束后,通过- (void)getReturnValue:(void *)retLoc;获取返回值。 这里需要注意,对NSInvocation对象设置的参数个数及类型和获取的返回值的类型要与创建对象时使用的NSMethodSignature对象代表的参数及返回值类型向一致,否则cresh。

##使用NSInvocation调用block 下面展示block 的两种调用方式

- (void)test{

    void (^block1)(int) = ^(int a){
         NSLog(@"block1 %d",a);
    };

    //type1
    block1(1);

    //type2
    //获取block类型对应的方法签名。
    NSMethodSignature *signature = aspect_blockMethodSignature(block1);
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
    [invocation setTarget:block1];
    int a=2;
    [invocation setArgument:&a atIndex:1];
    [invocation invoke];
}
复制代码

type1 就是常用的方法,不再赘述。看一下type2。 type2和上面调用method的type3用的一样的套路,只是参数不同:由block生成的NSInvocation对象的第一个参数是block本身,剩下的为 block自身的参数。

由于系统没有提供获取block的ObjCTypes的api,我们必须想办法找到这个ObjCTypes,只有这样才能生成NSMethodSignature对象! ###block的数据结构 & 从数据结构中获取 ObjCTypes oc是一门动态语言,通过编译 oc可以转变为c语言。经过编译后block对应的数据结构是struct。(block中技术点还是挺过的,推荐一本书“Objective-C 高级编程”)

//代码来自 Aspect
// Block internals.
typedef NS_OPTIONS(int, AspectBlockFlags) {
        AspectBlockFlagsHasCopyDisposeHelpers = (1 << 25),
        AspectBlockFlagsHasSignature          = (1 << 30)
};
typedef struct _AspectBlock {
        __unused Class isa;
        AspectBlockFlags flags;
        __unused int reserved;
        void (__unused *invoke)(struct _AspectBlock *block, ...);
        struct {
                unsigned long int reserved;
                unsigned long int size;
                // requires AspectBlockFlagsHasCopyDisposeHelpers
                void (*copy)(void *dst, const void *src);
                void (*dispose)(const void *);
                // requires AspectBlockFlagsHasSignature
                const char *signature;
                const char *layout;
        } *descriptor;
        // imported variables
} *AspectBlockRef;
复制代码

在此结构体中 const char *signature 字段就是我们想要的。通过下面的方法获取signature并创建NSMethodSignature对象。

static NSMethodSignature *aspect_blockMethodSignature(id block, NSError **error) {
    AspectBlockRef layout = (__bridge void *)block;
        if (!(layout->flags & AspectBlockFlagsHasSignature)) {
        NSString *description = [NSString stringWithFormat:@"The block %@ doesn't contain a type signature.", block];
        AspectError(AspectErrorMissingBlockSignature, description);
        return nil;
    }
        void *desc = layout->descriptor;
        desc += 2 * sizeof(unsigned long int);
        if (layout->flags & AspectBlockFlagsHasCopyDisposeHelpers) {
                desc += 2 * sizeof(void *);
    }
        if (!desc) {
        NSString *description = [NSString stringWithFormat:@"The block %@ doesn't has a type signature.", block];
        AspectError(AspectErrorMissingBlockSignature, description);
        return nil;
    }
        const char *signature = (*(const char **)desc);
        return [NSMethodSignature signatureWithObjCTypes:signature];
}

另外一篇

RunTime的使用案例

RunTime的使用案例
From: https://juejin.im/post/5ade99faf265da0b886d0c49

RunTime这个概念几乎是老生常谈了,但是有一些人对这个一直是仅仅对概念的理解,对于用到实例的次数并不太多,这里我就来说一下我项目中一些用到的实例方法吧,里面包含OC和Swift双版本。要是对RunTime的基础该要还有一些不了解的同学,可以点击这里,进行一些概念的普及。

案例

  • 1、防止Button的暴力点击
  • 2、防止UITapGestureRecognizer的暴力点击
  • 3、扩大button的点击范围
  • 4、UIButton 点击事件带多参数
  • 5、给View添加ViewID标志
  • 6、全局返回手势
  • 7、对MJRefresh的封装
  • 8、对DZNEmptyDataSet的封装

1、防止Button的暴力点击

第一篇案例,就说一篇网络上到处都有的一类文章吧,网上一搜,满满的都是。大家应该对这个也是特别的了解吧,所以先从这里开始。

感觉OC版的都没有什么难点,需要注意的Swift版的交换时机。

OC版代码

+ (void)load{
    Method originalMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:));
    Method swizzledMethod = class_getInstanceMethod([self class], @selector(JH_SendAction:to:forEvent:));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}


static const void *ButtonDurationTime = @"ButtonDurationTime";
- (NSTimeInterval)durationTime{
    NSNumber *number = objc_getAssociatedObject(self, &ButtonDurationTime);
    return number.doubleValue;
}
- (void)setDurationTime:(NSTimeInterval)durationTime{
    NSNumber *number = [NSNumber numberWithDouble:durationTime];
    objc_setAssociatedObject(self, &ButtonDurationTime, number, OBJC_ASSOCIATION_COPY_NONATOMIC);

}

- (void)JH_SendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event{

    self.userInteractionEnabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.durationTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.userInteractionEnabled = YES;
    });

    [self JH_SendAction:action to:target forEvent:event];
}
复制代码

这里有三个小知识点

  • 1、+ (void)load{} 它是一个在整个文件被加载到运行时,在 main 函数调用之前被 ObjC 运行时调用的方法

规则一:父类先于子类调用 规则二:类先于分类调用

  • 2、objc_setAssociatedObject & objc_getAssociatedObject 给分类添加属性
  • 3、method_exchangeImplementations替换原方法实现

Swift版代码 我们知道在swift中取消了+load方法,然后swift4.0以后initialize()也被禁用了,所以想要在哪了实现交换方法还真的需要考虑一下了。这里我想到了三种方法
//部分代码是转载后觉得缺失代码新增的步骤, 函数名没更换,但是逻辑步骤是对的

  • 1、交换方法实用OC代码,然后用一个桥连接
  • 2、把交换方法放到application(_ application:, didFinishLaunchingWithOptions launchOptions: )中,这样交换方法也只会调用一次
  • 3、写一个静态方法,这里我就是实用的静态方法

    struct RunTimeButtonKey {

    ///连续两次点击相差时间
    static let timeInterval = UnsafeRawPointer.init(bitPattern: "timeInterval".hashValue)
    

    }
    extension UIButton {

    private static let changeFunction: () = {
        //交换方法
        let systemMethod = class_getInstanceMethod(UIButton.classForCoder(), #selector(self.sendAction))
        let swizzMethod = class_getInstanceMethod(UIButton.classForCoder(), #selector(self.swizzeMethod))
     //class_replaceMethod(object_getClass(self), #selector(self.swizzeMethod), method_getImplementation(originaMethodC), method_getTypeEncoding(originaMethodC))
       method_exchangeImplementations(systemMethod!, swizzMethod!)
    
        print("changeFunction")
    
    }()
    
    //添加属性,在设置 timeInterval 的时候 修改button的执行事件
    var timeInterval: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.timeInterval!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
    
            UIButton.changeFunction
        }
    
        get {
    
            return  objc_getAssociatedObject(self, RunTimeButtonKey.timeInterval!) as? CGFloat
        }
    
    }
    
    @objc private dynamic func mySendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
        self.isUserInteractionEnabled = false
        let time:TimeInterval = TimeInterval(timeInterval ?? 0.0)
        DispatchQueue.main.asyncAfter(deadline:.now() + time) {
            self.isUserInteractionEnabled = true
        }
        mySendAction(action, to: target, for: event)
    }
    

    }
    复制代码

其实我对这个方法也是不太满意,但是现在也没有想到更好的方法,哪位小伙伴想到了更好的方法,可以跟我交流一下

2、防止UITapGestureRecognizer的暴力点击

这里为什么要把UITapGestureRecognizer暴力点击也单独拿出来讨论一下呢,因为前一段时间项目中有很多的是执行的点击事件,但是因为是多处用到了,所以就想添加一个timeInterval来处理,但是我当时走到了一个误区。

当时我是参考防止Button的暴力点击的思路的,当时我在UITapGestureRecognizer中找到了addTarget:(id)target action:(SEL)action 这个方法,然后我就想着用一个自己写的方法来交换这个方法,因为button里面有sendAction:to:forEvent:,我当时不动脑子,直接就认为他们一样了,其实他们是有很大区别的sendAction:to:forEvent: 这个是执行的方法,我们交换的话就是交换的是执行方法,但是addTarget:(id)target action:(SEL)action 是添加方法,即使我们交换了,在执行的时候并没有什么变化的

后来一个偶然想起了可以在代理里面改变执行,思路就是添加一个timeInterval,然后在代理里面根据timeInterval设置UITapGestureRecognizer是否可用

OC版代码

@interface UITapGestureRecognizer ()
///时间间隔
@property (nonatomic,assign) NSTimeInterval duration;

@end

static const void *UITapGestureRecognizerduration = @"UITapGestureRecognizerduration";

@implementation UITapGestureRecognizer (JHExtension)



- (NSTimeInterval)duration{
    NSNumber *number = objc_getAssociatedObject(self, &UITapGestureRecognizerduration);
    return number.doubleValue;
}

- (void)setDuration:(NSTimeInterval)duration{
    NSNumber *number = [NSNumber numberWithDouble:duration];
    objc_setAssociatedObject(self, &UITapGestureRecognizerduration, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



/**
 添加点击事件

 @param target taeget
 @param action action
 @param duration 时间间隔
 */
- (instancetype)initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration{

    self = [super init];
    if (self) {
        self.duration = duration;
        self.delegate = self;
        [self addTarget:target action:action];
    }
    return self;

}


- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
    self.enabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.duration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.enabled = YES;
    });

    return YES;
}

@end
复制代码

我们使用UITapGestureRecognizer的时候,我们这么使用[[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(事件)];,所以我直接把方法定义为initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration这样就不改变我们平时的书写习惯了。

Swift版代码

import UIKit
struct RunTimeTapGestureKey {
    ///连续两次点击相差时间
    static let timeInterval = UnsafeRawPointer.init(bitPattern: "timeInterval".hashValue)
}

extension UITapGestureRecognizer:UIGestureRecognizerDelegate{
    //添加属性,在设置 timeInterval 的时候
    var timeInterval: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeTapGestureKey.timeInterval!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            self.delegate = self
        }
        get {
            return  objc_getAssociatedObject(self, RunTimeTapGestureKey.timeInterval!) as? CGFloat
        }
    }


    convenience init(target: Any?, action: Selector?,timeInterval:CGFloat) {
        self.init(target: target, action: action)
        self.timeInterval = timeInterval
        self.delegate = self  
    }


    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        self.isEnabled = false
        let time:TimeInterval = TimeInterval(timeInterval ?? 0.0)
        DispatchQueue.main.asyncAfter(deadline:.now() + time) {
            self.isEnabled = true
        }
        return true
    }
}

复制代码

3、扩大button的点击范围

开始之前我们首先需要介绍一个方法- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event

iOS系统检测到手指触摸(Touch)操作时会将其放入当前活动Application的事件队列,UIApplication会从事件队列中取出触摸事件并传递给key window(当前接收用户事件的窗口)处理,window对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图,称之为hit-test view。

window对象会在首先在view hierarchy的顶级view上调用hitTest:withEvent:,此方法会在视图层级结构中的每个视图上调用pointInside:withEvent:,如果pointInside:withEvent:返回YES,则继续逐级调用,直到找到touch操作发生的位置,这个视图也就是hit-test view。

hitTest:withEvent:方法的处理流程如下:

  1. 首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
  2. 若返回NO,则hitTest:withEvent:返回nil;
  3. 若返回YES,则向当前视图的所有子视图(subviews)发送hitTest:withEvent:消息,所有子视图的遍历顺序是从top到bottom,即从subviews数组的末尾向前遍历,直到有子视图返回非空对象或者全部子视图遍历完毕;
  4. 若第一次有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;
  5. 如所有子视图都返回非,则hitTest:withEvent:方法返回自身(self)。

操作思路

  • 1、我们自己添加属性,重新设置点击区域大小
  • 2、根据新的点击区域,重写hitTest:withEvent:方法

OC版代码

static const void *topNameKey = @"topNameKey";
static const void *rightNameKey = @"rightNameKey";
static const void *bottomNameKey = @"bottomNameKey";
static const void *leftNameKey = @"leftNameKey";


- (void)setEnlargeEdgeWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left{

    objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:right], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:bottom], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:left], OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (CGRect)enlargedRect
{
    NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey);
    NSNumber *rightEdge = objc_getAssociatedObject(self, &rightNameKey);
    NSNumber *bottomEdge = objc_getAssociatedObject(self, &bottomNameKey);
    NSNumber *leftEdge = objc_getAssociatedObject(self, &leftNameKey);
    if (topEdge && rightEdge && bottomEdge && leftEdge) {
        return CGRectMake(self.bounds.origin.x - leftEdge.floatValue,
                          self.bounds.origin.y - topEdge.floatValue,
                          self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue,
                          self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue);
    }
    else
    {
        return self.bounds;
    }
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect rect = [self enlargedRect];
    if (CGRectEqualToRect(rect, self.bounds)) {
        return [super hitTest:point withEvent:event];
    }
    return CGRectContainsPoint(rect, point) ? self : nil;
}

复制代码

Swift版代码

//MARK: -- 扩大点击响应事件 --
struct RunTimeButtonKey {

    ///点击区域
    static let topNameKey = UnsafeRawPointer.init(bitPattern: "topNameKey".hashValue)
    static let rightNameKey = UnsafeRawPointer.init(bitPattern: "rightNameKey".hashValue)
    static let bottomNameKey = UnsafeRawPointer.init(bitPattern: "bottomNameKey".hashValue)
    static let leftNameKey = UnsafeRawPointer.init(bitPattern: "leftNameKey".hashValue)

}
extension UIButton {

    var topEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.topNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.topNameKey!) as? CGFloat
        }
    }
     var leftEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.leftNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.leftNameKey!) as? CGFloat
        }
    }
     var rightEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.rightNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.rightNameKey!) as? CGFloat
        }
    }

    var bottomEdge: CGFloat? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.bottomNameKey!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
            UIButton.changeFunction
        }

        get {

            return  objc_getAssociatedObject(self, RunTimeButtonKey.bottomNameKey!) as? CGFloat
        }
    }


    /// 扩大点击区域
    ///
    /// - Parameters:
    ///   - top: 上
    ///   - right: 右
    ///   - bottom: 下
    ///   - left: 左
    func setEnlargeEdge(top:CGFloat,right:CGFloat,bottom:CGFloat,left:CGFloat)  {
        self.topEdge = top
        self.rightEdge = right
        self.bottomEdge = bottom
        self.leftEdge = left

    }

    open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let left = self.leftEdge ?? 0
        let right = self.rightEdge ?? 0
        let bottom = self.bottomEdge ?? 0
        let top = self.topEdge ?? 0

        let rect:CGRect = CGRect(x: self.bounds.origin.x - left,
                             y: self.bounds.origin.y - top,
                             width: self.bounds.size.width + left + right, height: self.bounds.size.height + top + bottom)


        return rect.contains(point) ? self : nil
    }

}

复制代码

4、UIButton 点击事件带多参数

iOS 原生的 UIButton 点击事件是不允许带多参数的,唯一的一个参数就是默认UIButton本身 那么我们该怎么实现传递多个参数的点击事件呢?

  • 1、如果业务场景非常简单,要求传单参数并且是整数类型,可以用tag
  • 2、利用ObjC关联,runtime之所以被称为iOS 的动态特性是有道理的,当然关联甚至可以帮助NSArray等其他对象实现“多参数传递”

OC版代码

static const void *RunTimeButtonParam = @"RunTimeButtonParam";
- (NSDictionary*)ButtonParam{
    NSDictionary *param = objc_getAssociatedObject(self, &RunTimeButtonParam);
    return param;
}
- (void)setButtonParam:(NSDictionary *)ButtonParam{
    objc_setAssociatedObject(self, &RunTimeButtonParam, ButtonParam, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

复制代码

Swift版代码

//MARK: -- 携带参数 --
extension UIButton {
    var buttonParam: Dictionary<String, Any>? {
        set {
            objc_setAssociatedObject(self, RunTimeButtonKey.RunTimeButtonParam!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RunTimeButtonKey.RunTimeButtonParam!) as? Dictionary
        }
    }
}
复制代码

5、给View添加ViewID标志

其实这个跟第四个案例是一样的,但是这里写出来是为了让大家有一个跟多的对比,同时也可以扩展一下思维。

为什么我会给view添加ViewID标志呢,在前一段时间做项目的时候,我需要给view添加标志,标记我点了哪一个view,我们知道我们iOS开发中tag是int类型的,如果后台给我们的id都是值类型的那一般都没有什么太大问题,关键是有的时候后台的id是字符串类型,有字母也有数字,这个时候我们就不能用tag来标记了,而使用字符串类型的ViewID标志那就十分的适合了。

OC版代码

static const void *RunTimeViewID = @"RunTimeViewID";
static const void *RunTimeViewParam = @"RunTimeViewParam";

@implementation UIView (JHExtension)

- (NSString *)viewID{
    NSString *ID = objc_getAssociatedObject(self, &RunTimeViewID);
    return ID;
}
- (void)setViewID:(NSString *)viewID{
    objc_setAssociatedObject(self, &RunTimeViewID, viewID, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



- (NSDictionary *)viewParam{
    NSDictionary *param = objc_getAssociatedObject(self, &RunTimeViewParam);
    return param;
}
- (void)setViewParam:(NSDictionary *)viewParam{
     objc_setAssociatedObject(self, &RunTimeViewParam, viewParam, OBJC_ASSOCIATION_COPY_NONATOMIC);
}


@end

复制代码

Swift版代码

struct RunTimeViewKey {
    static let RunTimeViewID = UnsafeRawPointer.init(bitPattern: "RunTimeViewID".hashValue)
    static let RunTimeViewParam = UnsafeRawPointer.init(bitPattern: "RunTimeViewParam".hashValue)
}

extension UIView {
    var ViewID: String? {
        set {
            objc_setAssociatedObject(self, RunTimeViewKey.RunTimeViewID!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)

        }
        get {
            return  objc_getAssociatedObject(self, RunTimeViewKey.RunTimeViewID!) as? String
        }
    }

    var ViewParam: Dictionary<String, Any>? {
        set {
            objc_setAssociatedObject(self, RunTimeViewKey.RunTimeViewParam!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)

        }
        get {
            return  objc_getAssociatedObject(self, RunTimeViewKey.RunTimeViewParam!) as? Dictionary
        }
    }
}
复制代码

6、全局返回手势

对于全局返回手势,虽然能够知道原理,但是因为跟原生手势有交互,本着对自己技术不相信的态度,我就简单的说一下原理,具体的我们还是最好使用大神的。

其实系统是自带返回手势的,但是他的返回手势是在最左边,我们要做的就是找到这个这个系统方法,然后把他对象设置给控制器的View

 // 打印系统自带滑动手势的代理对象
    NSLog(@"%@",self.interactivePopGestureRecognizer.delegate);

复制代码

我们发现打印方法为handleNavigationTransition

然后我们就可以上代码了

OC版代码

- (void)viewDidLoad {
    [super viewDidLoad];

    // 获取系统自带滑动手势的target对象
    id target = self.interactivePopGestureRecognizer.delegate;

    // 创建全屏滑动手势,调用系统自带滑动手势的target的action方法
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:target action:@selector(handleNavigationTransition:)];

    // 设置手势代理,拦截手势触发
    pan.delegate = self;

    // 给导航控制器的view添加全屏滑动手势
    [self.view addGestureRecognizer:pan];

    // 禁止使用系统自带的滑动手势
    self.interactivePopGestureRecognizer.enabled = NO;

}

// 什么时候调用:每次触发手势之前都会询问下代理,是否触发。
// 作用:拦截手势触发
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    // 注意:只有非根控制器才有滑动返回功能,根控制器没有。
    // 判断导航控制器是否只有一个子控制器,如果只有一个子控制器,肯定是根控制器
    if (self.childViewControllers.count == 1) {
        // 表示用户在根控制器界面,就不需要触发滑动手势,
        return NO;
    }
    return YES;
}

复制代码

注意:这些方法是写在UINavigationController里面的

文章参考自:【8行代码教你搞定导航控制器全屏滑动返回效果】 |那些人追的干货

Swift版代码

  override func viewDidLoad() {
        super.viewDidLoad()
        let target = self.interactivePopGestureRecognizer?.delegate
        let pan = UIPanGestureRecognizer(target: target, action: Selector(("handleNavigationTransition:")))
        pan.delegate = self
        self.view.addGestureRecognizer(pan)
        // 禁止使用系统自带的滑动手势
        self.interactivePopGestureRecognizer?.isEnabled = false;
    }

    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if (self.childViewControllers.count == 1) {
            // 表示用户在根控制器界面,就不需要触发滑动手势,
            return false;
        }
        return true;
    }
复制代码

这两个demo没有用到runtime的方法,但是也是用到了替代方法, 是一个点赞量接近5千的demo,里面运用了运行时, 这里 有一篇介绍的文章

7、对MJRefresh的封装

想必大部分人都用过MJRefresh这个刷新控件吧,在我刚开始使用的时候,在每一个刷新的地方都会重新的定义一下这个控件,每一个tableview中都会创建这个刷新控件,然后把他的属性在写一遍,但是作为一个程序员怎么容忍自己写那么多的无意义代码呢。我们知道tableviewcollectionView都是继承自scrollView,那么我们可以在 scrollView的分类里面添加一些方法,那么我们在以后使用的时候,就不需要一遍一遍的重复写无用代码了,只需要调用scrollView分类方法就可以了。

OC版代码

@implementation UIScrollView (JHRefresh)
/**
 添加刷新事件

 @param headerBlock 头部刷新
 @param footerBlock 底部刷新
 */
- (void)setRefreshWithHeaderBlock:(void(^)(void))headerBlock
                      footerBlock:(void(^)(void))footerBlock{
    if (headerBlock) {

        MJRefreshNormalHeader *header= [MJRefreshNormalHeader headerWithRefreshingBlock:^{
            if (headerBlock) {
                headerBlock();
            }
        }];
        header.stateLabel.font = [UIFont systemFontOfSize:13];
        header.lastUpdatedTimeLabel.font = [UIFont systemFontOfSize:13];

        self.mj_header = header;
    }

    if (footerBlock) {
        MJRefreshBackNormalFooter *footer = [MJRefreshBackNormalFooter footerWithRefreshingBlock:^{
            footerBlock();
        }];
        footer.stateLabel.font = [UIFont systemFontOfSize:13];
        [footer setTitle:@"暂无更多数据" forState:MJRefreshStateNoMoreData];
        [footer setTitle:@"" forState:MJRefreshStateIdle];
        self.mj_footer.ignoredScrollViewContentInsetBottom = 44;
        self.mj_footer = footer;
    }
}



/**
 开启头部刷新
 */
- (void)headerBeginRefreshing{
    [self.mj_header beginRefreshing];
}


/**
 没有更多数据
 */
- (void)footerNoMoreData{
    [self.mj_footer setState:MJRefreshStateNoMoreData];
}

/**
 结束刷新
 */
- (void)endRefresh{

    if (self.mj_header) {
        [self.mj_header endRefreshing];
    }
    if (self.mj_footer) {
        [self.mj_footer endRefreshing];
    }
}


复制代码

Swift版代码

import UIKit
import MJRefresh
extension UIScrollView {

    /// 添加刷新事件
    ///
    /// - Parameters:
    ///   - refreshHeaderClosure: 头部刷新
    ///   - refreshFooterClosure: 底部刷新
    func addRefreshWithScrollView(refreshHeaderClosure:@escaping()->(), refreshFooterClosure:@escaping()->())  {

        ///*******头部刷新*************
        let header:MJRefreshNormalHeader = MJRefreshNormalHeader.init {
           refreshHeaderClosure()
        }

        //自动改变透明度 (当控件被导航条挡住后不显示)
        header.isAutomaticallyChangeAlpha = true
        // 设置字体
        header.stateLabel.font = UIFont.systemFont(ofSize: 13)
        header.lastUpdatedTimeLabel.font = UIFont.systemFont(ofSize: 13)
        self.mj_header = header

        ///**********尾部刷新**********
        let foot:MJRefreshBackNormalFooter = MJRefreshBackNormalFooter.init {
            refreshFooterClosure()
        }

        foot.stateLabel.font = UIFont.systemFont(ofSize: 13)
        foot.setTitle("", for: MJRefreshState.idle)
        foot.setTitle("暂无更多数据", for: MJRefreshState.noMoreData)

        self.mj_footer = foot

    }


    /// 添加头部刷新事件
    ///
    /// - Parameter refreshClosure: 闭包回调
    func addRefreshHeaderWithScrollView(refreshClosure:@escaping()->()) {

        let header:MJRefreshNormalHeader = MJRefreshNormalHeader.init {
            refreshClosure()
            }  

        //自动改变透明度 (当控件被导航条挡住后不显示)
        header.isAutomaticallyChangeAlpha = true
        // 设置字体
        header.stateLabel.font = UIFont.systemFont(ofSize: 13)
        header.lastUpdatedTimeLabel.font = UIFont.systemFont(ofSize: 13)
        self.mj_header = header
    }

    /// 下拉加载
    ///
    /// - Parameters:
    ///   -  tableView: tableView
    ///   - refreshClosure: 闭包回调
    func addRefreshFooterWithScrollView(refreshClosure:@escaping()->()) {
        let foot:MJRefreshBackNormalFooter = MJRefreshBackNormalFooter.init {
            refreshClosure()
        }

        foot.stateLabel.font = UIFont.systemFont(ofSize: 13)
        foot.setTitle("", for: MJRefreshState.idle)
        foot.setTitle("暂无更多数据", for: MJRefreshState.noMoreData)

        self.mj_footer = foot

    }


    /// 结束刷新
    ///
    /// - Parameter tableView: tableView
    func endRefreshWithTableView() {

        if (self.mj_header != nil) {
            self.mj_header.endRefreshing()
        }
        if (self.mj_footer != nil) {
            self.mj_footer.endRefreshing()
        }

    }


    /// 没有数据
    func NOMoreData() {
        self.mj_footer.state = .noMoreData
    }

}

复制代码

8、对DZNEmptyDataSet的封装

其实这个跟上面那一个封装是一个类型的,oc版代码几乎都是一样的,但是swift代码里面会有一个小小的坑需要我们来填。

OC版代码.h文件里面暴露了一下的方法

@property (nonatomic) ClickBlock clickBlock;                // 点击事件
@property (nonatomic, assign) CGFloat offset;               // 垂直偏移量
@property (nonatomic, strong) NSString *emptyText;          // 空数据显示内容
@property (nonatomic, strong) UIImage *emptyImage;          // 空数据的图片


- (void)setupEmptyData:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text tapBlock:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset tapBlock:(ClickBlock)clickBlock;
- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset emptyImage:(UIImage *)image tapBlock:(ClickBlock)clickBlock;
复制代码

.m文件中

static const void *KClickBlock = @"clickBlock";
static const void *KEmptyText = @"emptyText";
static const void *KOffSet = @"offset";
static const void *Kimage = @"emptyImage";

@implementation UIScrollView (JHEmptyDataSet)



- (ClickBlock)clickBlock{
    return objc_getAssociatedObject(self, &KClickBlock);
}

- (void)setClickBlock:(ClickBlock)clickBlock{

    objc_setAssociatedObject(self, &KClickBlock, clickBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (NSString *)emptyText{
    return objc_getAssociatedObject(self, &KEmptyText);
}

- (void)setEmptyText:(NSString *)emptyText{
    objc_setAssociatedObject(self, &KEmptyText, emptyText, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (CGFloat)offset{

    NSNumber *number = objc_getAssociatedObject(self, &KOffSet);
    return number.floatValue;
}

- (void)setOffset:(CGFloat)offset{

    NSNumber *number = [NSNumber numberWithDouble:offset];

    objc_setAssociatedObject(self, &KOffSet, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
}


- (UIImage *)emptyImage{
    return objc_getAssociatedObject(self, &Kimage);
}

- (void)setEmptyImage:(UIImage *)emptyImage{
    objc_setAssociatedObject(self, &Kimage, emptyImage, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



- (void)setupEmptyData:(ClickBlock)clickBlock{
    self.clickBlock = clickBlock;
    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text tapBlock:(ClickBlock)clickBlock{

    self.clickBlock = clickBlock;
    self.emptyText = text;

    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset tapBlock:(ClickBlock)clickBlock{

    self.emptyText = text;
    self.offset = offset;
    self.clickBlock = clickBlock;

    self.emptyDataSetSource = self;
    if (clickBlock) {
        self.emptyDataSetDelegate = self;
    }
}


- (void)setupEmptyDataText:(NSString *)text verticalOffset:(CGFloat)offset emptyImage:(UIImage *)image tapBlock:(ClickBlock)clickBlock{

    self.emptyText = text;
    self.offset = offset;
    self.emptyImage = image;
    self.clickBlock = clickBlock;

    self.emptyDataSetSource = self;
    self.emptyDataSetDelegate = self;

}



// 空白界面的标题
- (NSAttributedString *)titleForEmptyDataSet:(UIScrollView *)scrollView{
    NSString *text = self.emptyText?:@"没有找到任何数据";
    UIFont *font = [UIFont systemFontOfSize:17.0];
    NSMutableAttributedString *attStr = [[NSMutableAttributedString alloc] initWithString:text];
    [attStr addAttribute:NSFontAttributeName value:font range:NSMakeRange(0, text.length)];
    [attStr addAttribute:NSForegroundColorAttributeName value:JHWordColorDark range:NSMakeRange(0, text.length)];

    return attStr;
}

// 空白页的图片
- (UIImage *)imageForEmptyDataSet:(UIScrollView *)scrollView{
    return self.emptyImage?:[UIImage imageNamed:@"mine"];
}

//是否允许滚动,默认NO
- (BOOL)emptyDataSetShouldAllowScroll:(UIScrollView *)scrollView {
    return YES;
}

// 垂直偏移量
- (CGFloat)verticalOffsetForEmptyDataSet:(UIScrollView *)scrollView{
    return self.offset;
}




- (void)emptyDataSet:(UIScrollView *)scrollView didTapView:(UIView *)view{
    if (self.clickBlock) {
        self.clickBlock();
    }
}

复制代码

swift版代码 这里出现了一个坑,就是swift中怎么在扩展中添加闭包回调属性。对于这个问题大家可以参考[这篇文章][5],关键就是一句话,要先定义一个类属性作为闭包容器,专门存放闭包的属性

[5]: https://link.juejin.im?target=https%3A%2F%2Fwww.jianshu.com%2Fp%2Fc6658ee16168

import UIKit
import DZNEmptyDataSet

struct RuntimeKey {
    ///空数据显示内容
    static let emptyText = UnsafeRawPointer.init(bitPattern: "emptyText".hashValue)
    ///空数据的图片
    static let emptyImage = UnsafeRawPointer.init(bitPattern: "emptyImage".hashValue)
    ///垂直偏移量
    static let offset = UnsafeRawPointer.init(bitPattern: "offset".hashValue)
    ///点击回调闭包
    static var clickClosure = UnsafeRawPointer.init(bitPattern: "clickClosure".hashValue)

}
//MARK: -- 给UIScrollView添加属性 --
extension UIScrollView {
    ///空数据显示内容
    var emptyText: String? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.emptyText!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.emptyText!) as? String
        }
    }
    ///空数据的图片
    var emptyImage: String? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.emptyImage!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.emptyImage!) as? String
        }
    }

    ///垂直偏移量
    var offset: CGFloat? {
        set {
            objc_setAssociatedObject(self, RuntimeKey.offset!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }

        get {
            return  objc_getAssociatedObject(self, RuntimeKey.offset!) as? CGFloat
        }
    }

    //闭包回调
    typealias clickTipClosure = () -> Void
    // 定义一个类属性作为闭包的容器,专门存放闭包的属性
    private class BlockContainer: NSObject, NSCopying {
        func copy(with zone: NSZone? = nil) -> Any {
            return self
        }
        var clickTipClosure: clickTipClosure?
    }
    // 定义个一个计算属性
    private var newDataBlock: BlockContainer? {

        set(newValue) {
            objc_setAssociatedObject(self, RuntimeKey.clickClosure!, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
        }
        get {
            return objc_getAssociatedObject(self, RuntimeKey.clickClosure!) as? BlockContainer
        }

    }


}

//MARK: -- 给UIScrollView添加方法 --
extension UIScrollView :DZNEmptyDataSetSource,DZNEmptyDataSetDelegate{

    /// 设置空白页text。image。偏移量
    ///
    /// - Parameters:
    ///   - text: text
    ///   - image: image
    ///   - offSet: 偏移量
    func SetUPEmptyTextWithEmptyImageWithOffSet(text:String,image:String,offSet:CGFloat) {
        self.emptyText = text
        self.emptyImage = image
        self.offset = offSet

        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }

    /// 设置空白页text。image
    ///
    /// - Parameters:
    ///   - text: text
    ///   - image: image
    ///
    func SetUPEmptyTextWithEmptyImage(text:String,image:String){
        self.emptyText = text
        self.emptyImage = image


        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }


    /// 仅仅设置空白页图片
    ///
    /// - image: image
    func SetUPEmptyText(image:String){
         self.emptyImage = image
         self.emptyDataSetDelegate = self
         self.emptyDataSetSource = self
    }


    /// 仅仅设置空白页文本
    ///
    /// - Parameter text: text
    func SetUPEmptyText(text:String){
        self.emptyText = text
        self.emptyDataSetDelegate = self
        self.emptyDataSetSource = self
    }

    ///点击空白页回调
    func obtainClickClosure(Closure:@escaping clickTipClosure) {
        // 创建blockContainer,将外界传来的闭包赋值给类属性中的闭包变量
        let blockContainer: BlockContainer = BlockContainer()
        blockContainer.clickTipClosure = Closure
        self.newDataBlock = blockContainer
    }


}




//MARK: - DZNEmptyDataSetSource -
extension UIScrollView {
    // 空白界面的标题
    public func title(forEmptyDataSet scrollView: UIScrollView!) -> NSAttributedString! {

        guard self.emptyText != nil else {
            return nil
        }

        let text = self.emptyText ?? ""
        let attStr = NSMutableAttributedString.init(string: text)
        attStr.addAttribute(NSAttributedStringKey.strokeColor, value: UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 1), range: NSMakeRange(0, text.count))

        attStr.addAttribute(NSAttributedStringKey.font, value: UIFont.systemFont(ofSize: 17), range: NSMakeRange(0, text.count))

        return attStr
    }
    // 空白页的图片
    public func image(forEmptyDataSet scrollView: UIScrollView!) -> UIImage! {

        guard self.emptyImage != nil else {
            return nil
        }

        return UIImage.init(named: self.emptyImage!)
    }

    //是否允许滚动,默认NO
    public func emptyDataSetShouldAllowScroll(_ scrollView: UIScrollView!) -> Bool {
        return true
    }

    // 垂直偏移量
    public func verticalOffset(forEmptyDataSet scrollView: UIScrollView!) -> CGFloat {
        let set = self.offset ?? -50.0
        return CGFloat(set)
    }

    //点击
    public func emptyDataSet(_ scrollView: UIScrollView!, didTap view: UIView!) {
        self.newDataBlock?.clickTipClosure!()
    }

}


复制代码

iOS崩溃捕捉和分析

From: https://www.jianshu.com/p/09b6084bcd01

一、 崩溃日志

  • 1 什么是崩溃日志
    iOS设备上的应用闪退时, 操作系统会声称一个崩溃日志, 保存在设备上。

    路径是: 设置 -> 隐私 ->诊断与用量 ->诊断与用量数据。在这里可以看到设备上所有的设备崩溃日志.
    在“诊断与用量”界面,建议用户选择自动发送,这样可以每天自动发送诊断和用量数据到itunes,来帮助开发者分析崩溃.

  • 2如何获取崩溃日志
    2.1 连接设备获取崩溃日志
    设备与电脑上的ITunes Store同步后, 会将崩溃日志保存在电脑上,崩溃日志保存在以下位置:

    Mac OS X: ~/Library/Logs/CrashReporter/MobileDevice/

    可以看到所有和该电脑同步过的设备的崩溃日志(.crash文件)

iOS设备上的崩溃日志

2.2 通过Xcode获取崩溃日志
打开Xcode, 菜单栏上选择Window ->Devices,选中设备,点击View Device Logs -> All logs可以看到所有的崩溃日志。
选中某一个崩溃日志,点击Export Log可导出崩溃日志(.crash文件)

Xcode 查看崩溃日志

2.3 通过iTunes Connect获取使用者上传的崩溃日志
登录iTunes Connect, 选中APP, 点击可供销售的APP(即当前最新版本), 在最下面选中额外信息下的崩溃报告, 可以看到所有iOS版本下的崩溃报告。

iTunes Connect 崩溃日志

二、iOS 崩溃日志分析

首先来看一份崩溃日志

iOS崩溃日志

(1)Incident Identifier: 是崩溃报告的唯一标识符。
(2)CrashReporter Key: 是与设备标识相对应的唯一键值。虽然它不是真正的设备标识符,但也是一个非常有用的情报:如果你看到100个崩溃日志的CrashReporter Key值都是相同的,或者只有少数几个不同的CrashReport值,说明这不是一个普遍的问题,只发生在一个或少数几个设备上。
(3)Hardware Model: 标识设备类型。 如果很多崩溃日志都是来自相同的设备类型,说明应用只在某特定类型的设备上有问题。上面的日志里,崩溃日志产生的设备是iPhone 6(但是显示的是iPhone7,2? 暂时不清楚原因)。
(4)Process 是应用名称。中括号里面的数字是闪退时应用的进程ID。
(5)Version: App版本号
最重要的两部分
(1)Exception Type:EXC_CRASH (SIGABRT)
(2)Last Exception Backtrace(即发生崩溃的原因,也是我们要研究的重点)

Xcode会自动符号化代码, 翻译成明文, 如下:

Crash Logs

可以看到发生崩溃的代码位于[SCHomePageVC viewDidLoad]方法中第408行。
崩溃的代码是[NSArrayM insertObject:atIndex:]。
找到该行代码,可以看到崩溃日志中所描述的崩溃发生的位置,代码都和时机代码一致。

崩溃的代码

崩溃的原因是: The object to add to the array’s content. This value must not be nil.

三、如何通过.crash文件反编译得到明文的crash文件

步骤如下:
  • Step1: 在桌面上创建一个空的文件夹, 我将其命名为 DebugTest , 然后将三个文件放入该文件夹 “MyApp.app” , “MyApp.app.dSYM”, “MyApp_2016_4_1.crash”。
  • Step2 : 打开Applications文件夹,找到 symbolicatecrash 文件, Xcode和Xcode以上,文件位置

    //终端中输入以下命令:
    cd /Applications/Xcode.app/Contents/SharedFrameworks/DTDeviceKitBase.framework/Versions/A/Resources

然后你会发现symbolicatecrash文件,长这个样子,将其拷贝到DebugTest文件夹中

symbolicatecrash

到这一步,你的DebugTest目录机构应该是这样
(1MyAPP.app
(2)MyApp.app.dSYM
(3)MyApp_2016_4_1.crash
(4)symbolicatecrash

  • Step3: 在终端中输入以下3条命令

    //第一条命令(其中Yourname 应该是你的用户名)
    cd /Users/Yourname/Desktop/DebugTest
    // 第二条命令
    export DEVELOPER_DIR=”/Applications/Xcode.app/Contents/Developer”
    //第三条命令(二选一)
    (Xcode6.3和之前版本输入以下命令)
    ./symbolicatecrash -A -v MYApp_2016-4-1.crash MyApp.app.dSYM
    (Xcode6.4和之前版本输入以下命令)
    ./symbolicatecrash -v MYApp_2016-4-1.crash MyApp.app.dSYM

然后用控制台打开你的MyApp_2016_4_1.crash文件, 你就会看到编译后的crash文件, 同Xcode看到的崩溃日志一致。通过查看崩溃日志,可以轻易的找到崩溃原因并修正。

Crash Logs

四、如何在程序崩溃时手动捕捉到崩溃

   当我们debug的时候, 发生崩溃后可以在控制台上看到崩溃的堆栈信息和崩溃日志。上面三种方法都是我们获取.crash文件后解析的办法, 那么如果用户不发送崩溃日志到iTunes Connect时,我们如何获取崩溃信息呢?(尽可能的获取崩溃信息有助于热修复时定位代码)。当然,友盟支持搜集崩溃日志,那我们是否也可以在程序崩溃时,将崩溃信息写入本地,APP再次启动时,将崩溃信息上传到我们的服务器。这里就要用到apple的一个函数:NSSetUncaughtExceptionHandler。上代码:


//application didFinishLaunchingWithOptions中调用 [self catchCrashLogs];

- (void)catchCrashLogs{
    NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}
void UncaughtExceptionHandler(NSException *exception){
    if (exception ==nil)return;
    NSArray *array = [exception callStackSymbols];
    NSString *reason = [exception reason];
    NSString *name  = [exception name];
    NSDictionary *dict = @{@"appException":@{@"exceptioncallStachSymbols":array,@"exceptionreason":reason,@"exceptionname":name}};
    if([SDFileToolClass writeCrashFileOnDocumentsException:dict]){
        NSLog(@"Crash logs write ok!");
    }
}
//写入缓存中: 以下提供三个API,分别是:写入,获取,清空
NSString * const SDCrashFileDirectory = @"SDMapHomeCrashFileDirectory"; //你的项目中自定义文件夹名
+ (NSString *)sd_getCachesPath{
    return [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
}
+ (BOOL)writeCrashFileOnDocumentsException:(NSDictionary *)exception{
    NSString *time = [[NSDate date] formattedDateWithFormat:@"yyyyMMddHHmmss" locale:[NSLocale currentLocale]];
    NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
    NSString *crashname = [NSString stringWithFormat:@"%@_%@Crashlog.plist",time,infoDictionary[@"CFBundleName"]];
    NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
    NSFileManager *manager = [NSFileManager defaultManager];
    //设备信息
    NSMutableDictionary *deviceInfos = [NSMutableDictionary dictionary];
    [deviceInfos setObject:[infoDictionary objectForKey:@"DTPlatformVersion"] forKey:@"DTPlatformVersion"];
    [deviceInfos setObject:[infoDictionary objectForKey:@"CFBundleShortVersionString"] forKey:@"CFBundleShortVersionString"];
    [deviceInfos setObject:[infoDictionary objectForKey:@"UIRequiredDeviceCapabilities"] forKey:@"UIRequiredDeviceCapabilities"];

    BOOL isSuccess = [manager createDirectoryAtPath:crashPath withIntermediateDirectories:YES attributes:nil error:nil];
    if (isSuccess) {
        NSLog(@"文件夹创建成功");
        NSString *filepath = [crashPath stringByAppendingPathComponent:crashname];
        NSMutableDictionary *logs = [NSMutableDictionary dictionaryWithContentsOfFile:filepath];
        if (!logs) {
            logs = [[NSMutableDictionary alloc] init];
        }
        //日志信息
        NSDictionary *infos = @{@"Exception":exception,@"DeviceInfo":deviceInfos};
        [logs setObject:infos forKey:[NSString stringWithFormat:@"%@_crashLogs",infoDictionary[@"CFBundleName"]]];
        BOOL writeOK = [logs writeToFile:filepath atomically:YES];
        NSLog(@"write result = %d,filePath = %@",writeOK,filepath);
        return writeOK;
    }else{
        return NO;
    }
}
+ (nullable NSArray *)sd_getCrashLogs{
     NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
     NSFileManager *manager = [NSFileManager defaultManager];
     NSArray *array = [manager contentsOfDirectoryAtPath:crashPath error:nil];
     NSMutableArray *result = [NSMutableArray array];
    if (array.count == 0) return nil;
    for (NSString *name in array) {
        NSDictionary *dict = [NSDictionary dictionaryWithContentsOfFile:[crashPath stringByAppendingPathComponent:name]];
        [result addObject:dict];
    }
    return result;
}
+ (BOOL)sd_clearCrashLogs{
     NSString *crashPath = [[self sd_getCachesPath] stringByAppendingPathComponent:SDCrashFileDirectory];
    NSFileManager *manager = [NSFileManager defaultManager];
    if (![manager fileExistsAtPath:crashPath]) return YES; //如果不存在,则默认为删除成功
    NSArray *contents = [manager contentsOfDirectoryAtPath:crashPath error:NULL];
    if (contents.count == 0) return YES;
    NSEnumerator *enums = [contents objectEnumerator];
    NSString *filename;
    BOOL success = YES;
    while (filename = [enums nextObject]) {
        if(![manager removeItemAtPath:[crashPath stringByAppendingPathComponent:filename] error:NULL]){
            success = NO;
            break;
        }
    }
    return success;
}

Well done!

五、结论: 为了更好的分析崩溃原因,在每次上架APP的时候,应该保留对应的app文件和dsym文件。

六、参考链接:

iOS 私有api截屏

From: https://www.bbsmax.com/A/o75N8DeMzW/

昨天写了个用到截屏功能的插件,结果问题不断,今天终于解决好了,把debug过程中所有尝试过的截屏方法都贴出来吧~

第一种

这是iOS 3时代开始就被使用的方法,它被废止于iOS 7。iOS的私有方法,效率很高。

#import
extern "C" CGImageRef UIGetScreenImage();
UIImage * screenshot(void) NS_DEPRECATED_IOS(3_0,7_0);
UIImage * screenshot(){
    UIImage *image = [UIImage imageWithCGImage:UIGetScreenImage()];
    return image;
}

第二种

这是在比较常见的截图方法,不过不支持Retina屏幕。

UIImage * screenshot(UIView *);
UIImage * screenshot(UIView *view){
    UIGraphicsBeginImageContext(view.frame.size);
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第三种

从iPhone 4、iPod Touch 4开始,Apple逐渐采用Retina屏幕,于是在iOS 4的SDK中我们有了,上面的截图方法也自然变成了这样。

UIImage * screenshot(UIView *) NS_AVAILABLE_IOS(4_0);
UIImage * screenshot(UIView *view){
    if(UIGraphicsBeginImageContextWithOptions != NULL)
    {
        UIGraphicsBeginImageContextWithOptions(view.frame.size, NO, 0.0);
    } else {
        UIGraphicsBeginImageContext(view.frame.size);
    }
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第四种

或许你会说有时Hook的是一个按钮的方法,用第三个方法的话,根本找不到view来传值,不过还好,iOS 7又提供了一些UIScreen的API。

UIImage * screenshot(void) NS_AVAILABLE_IOS(7_0);
UIImage * screenshot(){
    UIView * view = [[UIScreen mainScreen] snapshotViewAfterScreenUpdates:YES];
    if(UIGraphicsBeginImageContextWithOptions != NULL)
    {
        UIGraphicsBeginImageContextWithOptions(view.frame.size, NO, 0.0);
    } else {
        UIGraphicsBeginImageContext(view.frame.size);
    }
    [view.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
}

第五种

@interface SBScreenShotter : NSObject
+ (id)sharedInstance;
- (void)saveScreenshot:(_Bool)arg1;
@end

然后直接

[[SBScreenShotter sharedInstance] saveScreenshot:YES];

一 道白光之后,咱们就模拟了用户截屏的动作,不过这个方法在只需要截屏时比较好,如果要对屏幕录像(其实就是不断截图)的话,那不得闪瞎了。。而且我们也拿 不到UIImage的实例去拼成一个视频呀。即使通过Hook别的类拿到UIImage的实例,这个私有API的效率大概也是达不到30FPS的视频要求 的。

那么现在我们有5种方法了,第一种是私有API,私有API通常效率和质量都比Documented API的好,可是它在iOS 7以后就被废除了啊,就没有别的了吗?

答案当然是————有的!用Private Framework来完成这项任务!直接走底层拿屏幕的缓冲数据,然后生成UIImage的实例。

第六种

#import #import #import #import #import extern "C" IOReturn IOSurfaceLock(IOSurfaceRef buffer, uint32_t options, uint32_t *seed);
extern "C" IOReturn IOSurfaceUnlock(IOSurfaceRef buffer, uint32_t options, uint32_t *seed);
extern "C" size_t IOSurfaceGetWidth(IOSurfaceRef buffer);
extern "C" size_t IOSurfaceGetHeight(IOSurfaceRef buffer);
extern "C" IOSurfaceRef IOSurfaceCreate(CFDictionaryRef properties);
extern "C" void *IOSurfaceGetBaseAddress(IOSurfaceRef buffer);
extern "C" size_t IOSurfaceGetBytesPerRow(IOSurfaceRef buffer);
extern const CFStringRef kIOSurfaceAllocSize;
extern const CFStringRef kIOSurfaceWidth;
extern const CFStringRef kIOSurfaceHeight;
extern const CFStringRef kIOSurfaceIsGlobal;
extern const CFStringRef kIOSurfaceBytesPerRow;
extern const CFStringRef kIOSurfaceBytesPerElement;
extern const CFStringRef kIOSurfacePixelFormat;
enum
{
    kIOSurfaceLockReadOnly  =0x00000001,
    kIOSurfaceLockAvoidSync =0x00000002
};
UIImage * screenshot(void);
UIImage * screenshot(){
    IOMobileFramebufferConnection connect;
    kern_return_t result;
CoreSurfaceBufferRef screenSurface = NULL;
    io_service_t framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleH1CLCD"));
if(!framebufferService)
        framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleM2CLCD"));
if(!framebufferService)
        framebufferService = IOServiceGetMatchingService(kIOMasterPortDefault,IOServiceMatching("AppleCLCD"));
result = IOMobileFramebufferOpen(framebufferService, mach_task_self(), 0, &connect);
result = IOMobileFramebufferGetLayerDefaultSurface(connect, 0, &screenSurface);
    uint32_t aseed;
    IOSurfaceLock((IOSurfaceRef)screenSurface, 0x00000001, &aseed);
    size_t width = IOSurfaceGetWidth((IOSurfaceRef)screenSurface);
    size_t height = IOSurfaceGetHeight((IOSurfaceRef)screenSurface);
    CFMutableDictionaryRef dict;
size_t pitch = width*4, size = width*height*4;
    int bPE=4;
    char pixelFormat[4] = {'A','R','G','B'};
    dict = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
    CFDictionarySetValue(dict, kIOSurfaceIsGlobal, kCFBooleanTrue);
    CFDictionarySetValue(dict, kIOSurfaceBytesPerRow, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &pitch));
    CFDictionarySetValue(dict, kIOSurfaceBytesPerElement, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &bPE));
    CFDictionarySetValue(dict, kIOSurfaceWidth, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &width));
    CFDictionarySetValue(dict, kIOSurfaceHeight, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &height));
    CFDictionarySetValue(dict, kIOSurfacePixelFormat, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, pixelFormat));
    CFDictionarySetValue(dict, kIOSurfaceAllocSize, CFNumberCreate(kCFAllocatorDefault,kCFNumberSInt32Type, &size));
    IOSurfaceRef destSurf = IOSurfaceCreate(dict);
    IOSurfaceAcceleratorRef outAcc;
    IOSurfaceAcceleratorCreate(NULL, 0, &outAcc);
    IOSurfaceAcceleratorTransferSurface(outAcc, (IOSurfaceRef)screenSurface, destSurf, dict,NULL);
    IOSurfaceUnlock((IOSurfaceRef)screenSurface, kIOSurfaceLockReadOnly, &aseed);
CFRelease(outAcc);
    CGDataProviderRef provider =  CGDataProviderCreateWithData(NULL,  IOSurfaceGetBaseAddress(destSurf), (width * height * 4), NULL);
    CGImageRef cgImage = CGImageCreate(width, height, 8,
8*4, IOSurfaceGetBytesPerRow(destSurf),
 CGColorSpaceCreateDeviceRGB(), kCGImageAlphaNoneSkipFirst |kCGBitmapByteOrder32Little,provider, NULL, YES, kCGRenderingIntentDefault);
    UIImage *image = [UIImage imageWithCGImage:cgImage];
    return image;
}

需要注意的是,第五种方法需要修改一下IOMobileFramebuffer的头文件。

typedef void * IOMobileFramebufferConnection;

In the reversed header, IOMobileFramebufferConnection is typedef’d to io_connect_t, which is typedef’d to io_object_t, which is mach_port_t, which is darwin_mach_port_t, which is darwin_mach_port_name_t, which is __darwin_natural_t, which is unsigned int! Int just happens to be pointer-sized on 32-bit, but is not under 64-bit。

——StackoverFlow

修改好的头文件顺便也丢上来吧,解压后放在Project的根目录下。

如果你使用的是theos的话,记得在Makefile里写上,

YOUR_TWEAK_NAME_PRIVATE_FRAMEWORKS = IOSurface IOKit IOMobileFramebuffer

YOUR_TWEAK_NAME_CFLAGS = -I./headers/ -I./headers/IOSurface

如 果是XCode上的Logos Tweak的话,在Build Settings -> Search Paths -> Header Search Paths里面添加一项:$(PROJECT_DIR)/YOUR_PROJECT_NAME/headers, 搜索方式为recursive. 最后在Build Phases里Link上IOSurface IOKit IOMobileFramebuffer这三个私有Framework。

实现AVPlayer的防录屏功能

实现AVPlayer的防录屏功能
From: http://huanhoo.net/2016/09/16/%E5%AE%9E%E7%8E%B0AVPlayer%E7%9A%84%E9%98%B2%E5%BD%95%E5%B1%8F%E5%8A%9F%E8%83%BD/

“AVPlayer”

前言

保护好第三方的版权是视频类公司要考虑的问题。如何防止用户通过录屏手段取得受版权保护的视频就是我们要讨论的内容。

常见录屏方法

  • 越狱
  • 私有Api
  • AirPlay
  • QuickTime

越狱

越狱后基本上想干啥都行了,小小的录屏功能更是不在话下。越狱市场上也有很多插件支持录屏的,随便搞来一个用就好。

阻止方法

想防越狱也比较简单,代码里可以判断当前设备是否越过狱,如果越狱了,就直接exit吧。判断越狱的方法在念茜的博客里有比较详细的介绍。

私有Api

以前有很多私有Api可以进行屏幕录制,都是通过截取一张张的图片来达到目的。但是随着SDK的升级,这些方法大多不管用了。目前能用的私有录屏Api只有一种,也就是IOSurface私有库。通过这个库可以在iOS9以下的设备上实现后台截屏功能,从而达到录屏效果。

阻止方法

从iOS9开始IOSurface库录屏的Api被去掉了,对于这种录屏手段我们的解决方法也比较简单粗暴,直接让自家的App从iOS9开始支持。除了这种方案没有其他的有效手段了。

AirPlay

通过AirPlay可以把当前屏幕投影到另外的屏幕上,比如投到PC上。那么PC上就有不少应用程序可以通过AirPlay录屏了。不光录屏,还可以进行视频剪辑,美化等一系列功能。

阻止方法

通过AirPlay不论是投到PC上,还是在本地起一个AirPlay的虚拟服务投到本地,通过[UIScreen screens].count取到的屏幕个数都会大于1,如果检测到屏幕个数大于1的话,可以直接exit或者暂停视频播放弹出提示。

QuickTime

我们今天着重讲这种录屏方式,这个比较王道了,苹果自家出的应用,也提供录屏功能,而且录出来的效果比其他方法的效果好很多。

阻止方法

QuickTime是通过抓取屏幕图像流来进行录屏的,它还有个优化体验的地方,就是如果当前屏幕中,有AVPlayer的话,它将和AVPlayer做同步,如果你播的是mp4文件,就直接拿AVPlayer中的mp4流。如果你使用HLS的方式,它将会读到你AVPlayer中下载下来的ts切片,拿到切片数据后进行播放录制。

听起来相当霸道了,然而苹果也给出了官方解答,简单解释一下就是通过给HLS流加密,可以达到防止录屏的效果,因为AVPlayer虽然可以拿到切片,但是没有key的话就无法对流进行解密。

HLS流是苹果自家的流媒体传输协议,具体的概念就不在此赘述了,网上资料很多。苹果的SDK中的AVPlayer对于HLS相关一系列的功能支持的都非常好,比如字幕,加解密这些功能。

HLS加密普遍是通过AES-128的方式给视频流加密,服务端和客户端都拿着同一个key,在m3u8文件中也会出现EXT-X-KEY字段:

EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=52",IV=0x9c7db8778570d05c3177c349fd9236aa  
  • METHOD:加密方式
  • URI:解密key的url地址
  • IV:可以当做是加密用的盐值

当AVPlayer开始加载m3u8文件时,会异步取ts切片和key,key如果通过http请求拿的话就比较危险了,可以考虑https,或者客户端本地组装key,比如和服务端的同学约定好,通过视频ID,类型ID等参数拼接在一起,再经过一系列加密后的结果作为key。

如果通过客户端自己组装key,组装好后要塞给AVPlayer,之后AVPlayer在拿到ts切片后,会自己用key来解密。

如果想这么做的话,我们需要将EXT-X-KEY项改成下面的写法:

EXT-X-KEY:METHOD=AES-128,URI="CustomScheme://priv.example.com/key.php?r=52",IV=0x9c7db8778570d05c3177c349fd9236aa  

URI这一项的Scheme变成了CustomScheme,后边具体是什么地址都不重要了,填个假的也可以。

AVPlayer在加载m3u8文件时,如果遇到用户自定义的Scheme,会认为你需要自己加载资源文件,例如key或者是ts切片。从而进入如下回调:

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {  



    dispatch_async(dispatch_get_main_queue(), ^{  



        NSString *scheme = [[[loadingRequest request] URL] scheme];  



            NSData *data = [self getDecryptKey];  



            if (data) {  

                [loadingRequest.dataRequest respondWithData:data];  

                [loadingRequest finishLoading];  

            }  



    });  



    return YES;  

}  

在回调中将自己组装的key传给AVPlayer,通过getDecryptKey函数获得key之后,就把data值赋给loadingRequest.dataRequest,再调用[loadingRequest finishLoading],AVPlayer就收到key了,后续的ts切片就将利用这个key来进行解密。

EXT-X-KEY字段可以有很多,意味着不同的ts切片将使用不同的key来解密。

通过这种方式就可以防止QuickTime的录屏,可以看到苹果自己SDK的AVPlayer对于自己的协议支持的相当好,回调用起来也非常方便。

安卓同学如果想支持HLS解密的话比较麻烦,因为安卓官方的播放器没有加载资源的回调。只能通过本地起Server,把key放到本地Server中,再替换EXT-X-KEY中的uri值来进行实现。或者使用Google自家开源的更强大的播放器EXO来实现。

后记

HLS加密的方式还是比较普遍的,然而一直也没有相关文章指出这种方式和QuickTime的防录屏之间有什么关系。

有同学想问如果用RTMP或者其他方式播放视频可以做到防录屏吗?只要你脱离AVPlayer去播放视频,最终拿到的都是一帧帧的视频画面,然后渲染到屏幕上,这样的话AVPlayer都是可以拿到屏幕画面流的。

唯一的方式是将RTMP转成HLS后加密,再通过AVPlayer来播,如果不能这样,就无法做到防录屏。


OC Runtime 修改YYModel nil变空字符处理

YYModel 没有好的 如果变量为nil 替换一个默认变量,OC 中 NSString 经常容易nil 崩溃, 所以有了此次需求,
注: 有一定的性能损失, 但是无需求的话不实现替换方法,性能损失应该很小, 有需求,用性能换稳定应该也划算

NSObject+YYMolde.h 新增

1
2
3
4
5
6
/**
转换完成后执行, 老变量新变量的替换
(只遍历当前类, 忽略父类)
@return <#return value description#>
*/
- (id)oldValueToNewValue:(id)value classTypeName:(const char *)classTypeName;

.m 新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 解析Property的Attributed字符串,参考Stackoverflow
*/
static const char *getPropertyType(objc_property_t property) {
const char *attributes = property_getAttributes(property);
// NSLog(@"%s", attributes);
char buffer[1 + strlen(attributes)];
strcpy(buffer, attributes);
char *state = buffer, *attribute;
while ((attribute = strsep(&state, ",")) != NULL) {
// 非对象类型
if (attribute[0] == 'T' && attribute[1] != '@') {
// 利用NSData复制一份字符串
return (const char *) [[NSData dataWithBytes:(attribute + 1) length:strlen(attribute) - 1] bytes];
// 纯id类型
} else if (attribute[0] == 'T' && attribute[1] == '@' && strlen(attribute) == 2) {
return "id";
// 对象类型
} else if (attribute[0] == 'T' && attribute[1] == '@') {
return (const char *) [[NSData dataWithBytes:(attribute + 3) length:strlen(attribute) - 4] bytes];
}
}
return "";
}

检查函数, 在合适的位置调用(model 转换完成后调用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
- (void)checkObj:(NSObject *)obj{
if ([obj respondsToSelector:@selector(oldValueToNewValue:classTypeName:)]){
@try{
unsigned int outCount, i;
//objc_property_t 数组获取
objc_property_t *properties = class_copyPropertyList([obj class], &outCount);
for (i = 0;i<outCount; i++){
// 单一的 objc_property_t
objc_property_t property = properties[i];
// 变量名获取
NSString *propertyName = [NSString stringWithUTF8String:property_getName(property)];
// 获取类型名称
const char * propertyTypeName = getPropertyType(property);
// 获取 property 值
id propertyValue = [obj valueForKey:propertyName];
id newValue = [(id<YYModel>)obj oldValueToNewValue:propertyValue classTypeName:propertyTypeName];
if (newValue != propertyValue){
[obj setValue:newValue forKey:propertyName];
}
}
free(properties);
// 如需遍历父类, 但是不需要的
// NSObject * subObj = obj.superclass;
// if (subObj){
//// NSLog(@"currentObj %@, subObjClassName---%@",[obj classForCoder],[subObj classForCoder]);
// [self checkObj:subObj];
// }
}@catch(NSException *exp){
}
}
}

然后自己的实现model 中 实现, 其他类型判断都可行

1
2
3
4
5
6
7
8
9
- (id)oldValueToNewValue:(id)value classTypeName:(const char *)classTypeName{
const char * className = NSStringFromClass([NSString class]).UTF8String;
if (strncmp(className, classTypeName, strlen(className)) == 0){
if(value == nil || value == NULL){
return @"";
}
}
return value;
}

重点 - 解析property_getAttributes函数的结果
在整个处理过程中,property_getAttributes函数是关键,因为我们要首先确定Property的类型,才能根据类型赋初值,但是property_getAttributes函数返回的字符串比较“晦涩难懂”:

如下定义的Property:

1
2
3
4
5
6
@property (copy, nonatomic) NSString *name;
@property (strong, nonatomic) NSNumber *number;
@property (strong, nonatomic) NSArray *array;
@property (assign, nonatomic) NSInteger i;
@property (assign, nonatomic) CGFloat f;
@property (assign, nonatomic) char *cStr;

依次通过property_getAttributes获取的结果是:

1
2
3
4
5
6
T@"NSString",C,N,V_name
T@"NSNumber",&,N,V_number
T@"NSArray",&,N,V_array
Tq,N,V_i
Td,N,V_f
T*,N,V_cStr

参考 Declared Properties of Objective-C Runtime Programming Guide
我们大概可以知道,T表示Type,后面跟着@表示Cocoa对象类型,后面的表示Property的属性,如Copy、strong等,然后就是变量名。
所以getPropertyType函数的工作就是纯粹的解析字符串,获取T@后面的类型名。

参考

git 地址

iOS音频播放 (九):边播边缓存

From: http://msching.github.io/blog/2016/05/24/audio-in-ios-9/

好久没写过博客了,在这期间有很多同学通过博客评论、微博私信、邮件和我交流iOS音频方面的相关问题,其中被问到最多的是如何实现“边播边缓存”,这篇就来说一说这个话题。顺便一提,本文的题目虽然为“iOS音频播放”,但其中所涉及的部分技术方案在OSX平台或者在流播放视频下同样适用。


这类的技术方案其实有不少(其实在第一篇的末尾也略微有所涉及):

思路1. 最直接的方式,自行实现音频数据的请求在请求的过程中把数据缓存到磁盘,然后基于磁盘的数据自己实现解码、播放等功能;这个方法作为直接也最为复杂,开发者需要对音频播放的原理、操作系统等知识有一定程度的理解。如果能够实现这种方式所达到的效果也将会是最好的,整个过程都由开发者掌控,出现问题也可以对症下药。开源播放器FreeStreamer就是一个很好的例子,使用带有cache功能开源播放器或在其基础上进行二次开发也是不错的选择;

思路2. 请求拦截的方式,首先你需要一个能够进行流播放的播放器(如Apple提供的AVPlayer),通过拦截播放器发送的请求可以知道需要下载哪一段数据,于是就可以根据本地缓存文件的情况分段为播放器提供数据,如遇到已缓存的数据段直接从缓存中获取数据塞回给播放器,如遇到未缓存的数据段就发送请求获取数据,得到response和数据后保存到磁盘同时塞回给播放器。这种思路下有三个分支:

思路2.1 流播放器 + LocalServer,首先在搭建一个LocalServer(例如使用GCDWebServer),然后将URL组织成类似这种形式:

http://localhost:port?url=urlEncode(audioUrl)

把组织好的URL交给播放器播放,播放器把请求发送到LocalServer上,LocalServer解析到实际的音频地址后发送请求或者读取已缓存的数据。

思路2.2 流播放器 + NSURLProtocol,大家都知道NSURLProtocol可以拦截Cocoa平台下URL Loading System中的请求,如果播放器的请求是运行在URL Loading System下的话使用这个方法可以轻松的拦截到播放器所发送的请求然后自己再进行请求或者读取缓存数据。这里需要注意如果使用AVPlayer作为播放器的话这种方法只在模拟器上才work,真机上并不能拦截到任何请求。这也证明AVPlayer在真机上并没有运行在URL Loading System下,但模拟器上却是(不知道在OSX下是否能work,有兴趣的同学可以尝试一下)。

注:如果播放器使用的是CFNetwork,也可以尝试拦截,例如使用FB的fishhook,这hook方法应该会遇上不少坑,请做好心理准备。。

思路2.3 AVPlayer + AVAssetResourceLoader,AVAssetResourceLoader是iOS 6之后添加的类其主要作用是让开发者能够掌控AVURLAsset加载数据的整个过程。这正好符合我们的需求,AVAssetResourceLoader会通过delegate把AVAssetResourceLoadingRequest对象传递给开发者,开发者可以根据其中的一些属性得知需要加载的数据段。在得到数据后也可以通过AVAssetResourceLoadingRequest向AVPlayer传递response和数据。

思路3. 取巧的方式,自行实现音频数据的请求在请求的过程中把数据缓存到磁盘,然后使用系统提供的播放器(如AVAudioPlayer、AVPlayer进行播放)。这种实现方式中需要注意的是要播放的音频文件需要预先缓存一定量之后才能够播放,具体缓存多少完全频个人感觉,并且有可能会产生播放失败或者播放错误。这种方式的另一个缺点是无法进行任意的seek;


上面提到了3种思路共5个方案,那么在实际开发过程中开发者应该可以根据各个方案的优劣结合自己的实际情况选择最适合自己的方案。

思路1:优点在于整个播放过程可控,出现问题可调试,但开发复杂度较高,故选择有对应功能的开源播放器是一个比较好途径。在使用开源播放器之前最好能阅读其代码,掌握整个播放流程,出了问题才能迅速定位。推荐以播放为核心功能的app使用此方案;

思路2:优点在于开发者不必关心播放的整个过程,对音频播放的相关知识也不必有太多的了解,整个开发过程只要关心请求的解析、缓存数据的读取和保存以及数据的回填即可;至于缺点,首先你的有一个靠谱的流播放器,如果使用AVPlayer那么请做踩坑准备;

思路2.1:各类流播放器通吃,如果方案2.2和2.3不管用2.1是最好的选择;

思路2.2:需要播放器有指定的请求方式,如运行在URL Loading System下;

思路2.3:如果你用的就是AVPlayer那么可以尝试使用这个思路,但对于播放列表形式(M3U8)的音频这种方式是无效的;

思路3:如果你选择这条路,那说明你真的懒得不行。。。


一般音频流或者视频流都会支持HTTP协议中的Range request header,所以大多数的流播放器都会对Range header进行支持,在数据源支持Range的情况下拦截到请求时有必要注意播放器所请求的数据段并根据当前数据缓存的状态进行分段处理。

举个例子,播放器请求bytes=0-100,其中10-20、50-60已经被缓存,那么这个请求就应该被分为下面几段来处理:

  1. 0-10,网络请求
  2. 10-20,本地缓存
  3. 20-50,网络请求
  4. 50-60,本地缓存
  5. 60-100,网络请求

以上几段数据请求按顺序执行并进行数据回填,其中通过网络请求的数据在收到之后加入缓存以便下一次请求再次使用。另外要注意的是由于播放器本身只发送了一个请求所以response还是只有一个并且Content-Range还是应该为0-100/FileLength


AVPlayerCacheSupport是我使用AVAssetResourceLoader进行实践后实现的一个开源项目,在开发的过程中踩到的坑也在这里分享给大家。

shceme必须自定义

非自定义的URL Scheme不会触发AVAssetResourceLoader的delegate方法。这一点并不难发现,Stackoverflow上和github上都有提到这一点。所以在构造AVPlayItem时必须使用自定义Scheme的URL才行,这里我是在原有的Scheme后加上了-streaming,在收到AVAssetResourceLoader的回调之后实际发送请求时再把-streaming后缀去掉。

AVURLAsset.resourceLoader的delegate必须在AVPlayerItem生成前赋值

看代码感受一下吧,这样写能接到回调:

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url] options:options];
[asset.resourceLoader setDelegate:self queue:dispatch_get_main_queue()];
AVPlayerItem *item = [self playerItemWithAsset:asset];

下面这种写法是无法接到回调的:

AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url] options:options];
AVPlayerItem *item = [self playerItemWithAsset:asset];
[[(AVURLAsset *)item.asset resourceLoader] setDelegate:self queue:dispatch_get_main_queue()];

不支持Playlist类型的播放

AVAssetResourceLoader不支持类似M3U和M3U8这类播放列表类型的流,这个问题的回答来自SO链接官方文档中关于HTTP Live Streaming的一段话也印证了这一点。

在搜索相关问题之前,我尝试了使用AVAssetResourceLoader去加载M3U8播放列表,其中M3U8文件可以获取到,但并非获取了之后直接存储就完事了,还需要进行一些处理:

M3U8中一般有两种类型的URL:相对地址的URL和绝对地址的URL,其中相对地址的URL不需要处理AVPlayer会根据原先的host(也就是带了-streaming后缀的host)进行请求,这样的请求还是会被AVAssetResourceLoader拦截到。而绝对地址的URL则需要对其中的scheme进行处理使其能够被AVAssetResourceLoader拦截。

处理完所有的URL以后才能把M3U8文件进行保存。

M3U8处理完成之后,就尝试处理其中的一些媒体文件地址,例如ts格式的视频,但经过尝试后发现这类ts的链接并不能被AVAssetResourceLoader拦截到,这才去搜索相关内容后找到了上述的SO链接和官方文档。

AVAssetResourceLoadingContentInformationRequest的contentLength和contentType

AVAssetResourceLoadingContentInformationRequestAVAssetResourceLoadingRequest的一个属性

/*! 
 @property       contentInformationRequest
 @abstract       An instance of AVAssetResourceLoadingContentInformationRequest that you should populate with information about the resource. The value of this property will be nil if no such information is being requested.
*/
@property (nonatomic, readonly, nullable) AVAssetResourceLoadingContentInformationRequest *contentInformationRequest NS_AVAILABLE(10_9, 7_0);

其作用是告诉AVPlayer当前加载的资源类型、文件大小等信息。

AVAssetResourceLoadingContentInformationRequest有这样一个属性:

/*! 
 @property       contentLength
 @abstract       Indicates the length of the requested resource, in bytes.
 @discussion Before you finish loading an AVAssetResourceLoadingRequest, if its contentInformationRequest is not nil, you should set the value of this property to the number of bytes contained by the requested resource.
*/
@property (nonatomic) long long contentLength;

乍看上去可以把当前所请求数据的Content-Length直接赋给这个属性,例如请求range=0-100的那么其Content-Length就是100。如果当前数据无缓存的话,就直接把NSURLResponseexpectedContentLength属性值赋值给了contentLength。

但经过实践发现上面的做法并不正确。对于支持Range的请求,如range=0-100,NSURLResponseexpectedContentLength属性值为100,但这里需要填入的是文件的总长。所以对于response header中包含Content-Range的请求,需要解析出其中的文件总长再赋值给AVAssetResourceLoadingContentInformationRequestcontentLength属性。

接下来是contentType

/*! 
 @property       contentType
 @abstract       A UTI that indicates the type of data contained by the requested resource.
 @discussion Before you finish loading an AVAssetResourceLoadingRequest, if its contentInformationRequest is not nil, you should set the value of this property to a UTI indicating the type of data contained by the requested resource.
*/
@property (nonatomic, copy, nullable) NSString *contentType;

这里的contentType是UTI,和NSURLResponseMIMEType并不相同。需要进行转换:

NSString *mimeType = [response MIMEType];
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(mimeType), NULL);
loadingRequest.contentInformationRequest.contentType = CFBridgingRelease(contentType);

要说的就这么多,希望能帮到大家 =)。

iOS音频篇:AVPlayer的缓存实现

From: https://www.jianshu.com/p/93ce1748ea57

在上一篇文章《使用AVPlayer播放网络音乐》介绍了AVPlayer的基本使用,下面介绍如何通过AVAssetResourceLoader实现AVPlayer的缓存

需求梳理

没有任何工具能适用于所有的场景,在使用AVPlayer的过程中,我们会发现它有很多局限性,比如播放网络音乐时,往往不能控制其内部播放逻辑,比如我们会发现播放时seek会失败,数据加载完毕后不能获取到数据文件进行其他操作,因此我们需要寻找弥补其不足之处的方法,这里我们选择了AVAssetResourceLoader。

AVAssetResourceLoader的作用:让我们自行掌握AVPlayer数据的加载,包括获取AVPlayer需要的数据的信息,以及可以决定传递多少数据给AVPlayer。

AVAssetResourceLoader在AVPlayer中的位置如下:*

Location.jpeg

实现核心

使用AVAssetResourceLoader需要实现AVAssetResourceLoaderDelegate的方法:

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest; 

要求加载资源的代理方法,这时我们需要保存loadingRequest并对其所指定的数据进行读取或下载操作,当数据读取或下载完成,我们可以对loadingRequest进行完成操作。

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader 
didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest; 

取消加载资源的代理方法,这时我们需要取消loadingRequest所指定的数据的读取或下载操作。

实现策略

通过AVAssetResourceLoader实现缓存的策略有多种,没有绝对的优与劣,只要符合我们的实际需求就可以了。

下面我们以模仿企鹅音乐的来演示AVAssetResourceLoader实现缓存的过程为例子。

先观察并猜测企鹅音乐的缓存策略(当然它不是用AVPlayer播放):
  1、开始播放,同时开始下载完整的文件,当文件下载完成时,保存到缓存文件夹中;
  2、当seek时
   (1)如果seek到已下载到的部分,直接seek成功;(如下载进度60%,seek进度50%)
   (2)如果seek到未下载到的部分,则开始新的下载(如下载进度60%,seek进度70%)
      PS1:此时文件下载的范围是70%-100%
      PS2:之前已下载的部分就被删除了
      PS3:如果有别的seek操作则重复步骤2,如果此时再seek到进度40%,则会开始新的下载(范围40%-100%)
  3、当开始新的下载之后,由于文件不完整,下载完成之后不会保存到缓存文件夹中;
  4、下次再播放同一歌曲时,如果在缓存文件夹中存在,则直接播放缓存文件;

实现流程

流程示意图:

1、通过自定义scheme来创建avplayer,并给AVURLAsset指定代理(SUPlayer对象)
AVURLAsset * asset = [AVURLAsset URLAssetWithURL:[self.url customSchemeURL] options:nil];            
[asset.resourceLoader setDelegate:self.resourceLoader queue:dispatch_get_main_queue()];
self.currentItem = [AVPlayerItem playerItemWithAsset:asset];
self.player = [AVPlayer playerWithPlayerItem:self.currentItem];
2、代理实现AVAssetResourceLoader的代理方法(SUResourceLoader对象)
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
    [self addLoadingRequest:loadingRequest];
    return YES;
}

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
    [self removeLoadingRequest:loadingRequest];
}
3、对loadingRequest的处理(addLoadingRequest方法)

(1)将其加入到requestList中

[self.requestList addObject:loadingRequest];

(2)如果还没开始下载,则开始请求数据,否则静待数据的下载

[self newTaskWithLoadingRequest:loadingRequest cache:YES];

(3)如果是seek之后的loadingRequest,判断请求开始的位置,如果已经缓冲到,则直接读取数据

if (loadingRequest.dataRequest.requestedOffset >= self.requestTask.requestOffset &&
    loadingRequest.dataRequest.requestedOffset <= self.requestTask.requestOffset + self.requestTask.cacheLength) {
    [self processRequestList];
}
3.4如果还没缓冲到,则重新请求
if (self.seekRequired) {
    [self newTaskWithLoadingRequest:loadingRequest cache:NO];
}
4、数据请求的处理(newTaskWithLoadingRequest方法)

(1)先判断是否已经有下载任务,如果有,则先取消该任务

if (self.requestTask) {
    fileLength = self.requestTask.fileLength;
    self.requestTask.cancel = YES;
}

(2)建立新的请求,设置代理

self.requestTask = [[SURequestTask alloc]init];
self.requestTask.requestURL = loadingRequest.request.URL;
self.requestTask.requestOffset = loadingRequest.dataRequest.requestedOffset;
self.requestTask.cache = cache;
if (fileLength > 0) {
    self.requestTask.fileLength = fileLength;
}
self.requestTask.delegate = self;
[self.requestTask start];
self.seekRequired = NO;
5、数据响应的处理(processRequestList方法)

  对requestList里面的loadingRequest填充响应数据,如果已完全响应,则将其从requestList中移除

- (void)processRequestList {
    NSMutableArray * finishRequestList = [NSMutableArray array];
    for (AVAssetResourceLoadingRequest * loadingRequest in self.requestList) {
        if ([self finishLoadingWithLoadingRequest:loadingRequest]) {
            [finishRequestList addObject:loadingRequest];
        }
    }
    [self.requestList removeObjectsInArray:finishRequestList];
}

  填充响应数据的过程如下:
(1)填写 contentInformationRequest的信息,注意contentLength需要填写下载的文件的总长度,contentType需要转换

CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(MimeType), NULL);
loadingRequest.contentInformationRequest.contentType = CFBridgingRelease(contentType);
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
loadingRequest.contentInformationRequest.contentLength = self.requestTask.fileLength;

(2)计算可以响应的数据长度,注意数据读取的起始位置是当前avplayer当前播放的位置,结束位置是loadingRequest的结束位置或者目前文件下载到的位置

NSUInteger cacheLength = self.requestTask.cacheLength;
NSUInteger requestedOffset = loadingRequest.dataRequest.requestedOffset;
if (loadingRequest.dataRequest.currentOffset != 0) {
    requestedOffset = loadingRequest.dataRequest.currentOffset;
}
NSUInteger canReadLength = cacheLength - (requestedOffset - self.requestTask.requestOffset);
NSUInteger respondLength = MIN(canReadLength, loadingRequest.dataRequest.requestedLength);

(3)读取数据并填充到loadingRequest

[loadingRequest.dataRequest respondWithData:[SUFileHandle readTempFileDataWithOffset:requestedOffset - self.requestTask.requestOffset length:respondLength]];

(4) 如果完全响应了所需要的数据,则完成loadingRequest,注意判断的依据是 响应数据结束的位置 >= loadingRequest结束的位置

NSUInteger nowendOffset = requestedOffset + canReadLength;
NSUInteger reqEndOffset = loadingRequest.dataRequest.requestedOffset + loadingRequest.dataRequest.requestedLength;
if (nowendOffset >= reqEndOffset) {
    [loadingRequest finishLoading];
    return YES;
}
return NO;
6、处理requestList的时机

当有新的loadingRequest或者文件下载进度更新时,都需要处理requestList

7、新的请求任务实现的过程(SURequestTask对象)

(1)初始化时,需要删除旧的临时文件,并创建新的空白临时文件

- (instancetype)init {
    if (self = [super init]) {
        [SUFileHandle createTempFile];
    }
    return self;
}

(2)建立新的连接,如果是seek后的请求,则指定其请求内容的范围

- (void)start {
    NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[self.requestURL originalSchemeURL] cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:RequestTimeout];
    if (self.requestOffset > 0) {
        [request addValue:[NSString stringWithFormat:@"bytes=%ld-%ld", self.requestOffset, self.fileLength - 1] forHTTPHeaderField:@"Range"];
    }
    self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    self.task = [self.session dataTaskWithRequest:request];
    [self.task resume];
}

(3)当收到数据时,将数据写入临时文件,更新下载进度,同时通知代理处理requestList

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    if (self.cancel) return;
    [SUFileHandle writeTempFileData:data];
    self.cacheLength += data.length;
    if (self.delegate && [self.delegate respondsToSelector:@selector(requestTaskDidUpdateCache)]) {
        [self.delegate requestTaskDidUpdateCache];
    }
}

(4)当下载完成时,如果满足缓存的条件,则将临时文件拷贝到缓存文件夹中

if (self.cache) {
    [SUFileHandle cacheTempFileWithFileName:[NSString fileNameWithURL:self.requestURL]];
}
if (self.delegate && [self.delegate respondsToSelector:@selector(requestTaskDidFinishLoadingWithCache:)]) {
    [self.delegate requestTaskDidFinishLoadingWithCache:self.cache];
}

示例Demo

以上就是总体的实现流程,当然每个人的思路都不同,你可以在对其理解得足够深刻之后使用更高效更安全的方式去实现。

本文的demo在我的github上可以下载:GitHub : SUCacheLoader

本demo是以缓存豆瓣FM的歌曲(MP4格式)为例写的,如果你追求更完美的效果,可以从以下几方面入手:
  1、对缓存格式支持的处理:并不是所有文件格式都支持的哦,对于不支持的格式,你应该不使用缓存功能;
  2、对缓存过程中各种错误的处理:比如下载超时、连接失败、读取数据错误等等的处理;
  3、缓存文件的命名处理,如果缓存文件没有后缀(如.mp4),可能会导致播放失败;
  4、AVPlayer播放状态的处理,要做到完美的播放体验,在这方面要下点功夫;

Next:

接下来将带来AudioFileStream + AudioQueue 播放本地文件、网络文件、缓存实现的讲解

侧滑手势问题

因为发现自己的项目里面的FD 侧滑,有bug 看到这个文章觉得很有道理记录下

From: https://juejin.im/post/5adeda3051882567336a5dc9

序言

   在ios7以后,苹果推出了手势滑动返回功能,也就是从屏幕左侧向右滑动可返回上一个界面。大大提高了APP在大屏手机和iPad上的操作体验,场景切换更加流畅。做右滑返回手势配置时,可能会遇到的问题:

   1. 右滑返回手势为什么失效?

   2. 右滑返回手势如何全局开启及怎么避免页面卡死?

   3. 特定页面停用右滑手势后如何再次开启?

   4. 右滑返回手势与滚动视图手势冲突怎么解决?

   5. 全屏右滑返回怎么设置?

问题分析

右滑返回手势为什么失效?

   右滑返回手势失效主要是因为自定义了页面中navigationItem的leftBarButtonItem或leftBarButtonItems,或是self.navigationItem.hidesBackButton = YES;隐藏了返回按钮,亦或是self.navigationItem.leftItemsSupplementBackButton = NO;,让我们来梳理下。    UINavigationItem(Apple文档)是一个常见的类,然而还有不少开发者对该类了解甚少,这里注重说明下backBarButtonItemleftBarButtonItemrightBarButtonItemleftItemsSupplementBackButton四个属性。leftBarButtonItem、rightBarButtonItem是在当前页面设置,并展示在当前页面的navigationItem上。backBarButtonItem若是在当前页面设置,却展示在次级页面navigationItem上。

   比如在AViewController push BViewController时,在A设置了self.navigationItem.backBarButtonItem的title和image,经过试验发现,这个backBarButtonItem为BViewController的self.navigationController.navigationBar.backItem.backBarButtonItem。虽然self.navigationController.navigationBar.backItem.backBarButtonItem 是读写属性,但是self.navigationController、self.navigationController.navigationBar、 self.navigationController.navigationBar.backItem,都是readonly属性,因此backBarButtonItem,只能在AViewController中定义并在Push:BViewController之前进行设置。leftBarButtonItem、rightBarButtonItem可以在BViewController的ViewDidLoad后设置。

   注意backBarButtonItem只能自定义image和title,不能重写target 或 action,系统会忽略其他的相关设置项。如果硬是需要重写action做一些其他的工作,则需要自定义一个leftBarButtonItem。    系统默认情况下leftBarButtonItem的优先级是要高于backBarButtonItem的,当存在leftBarButtonItem时,自动忽略backBarButtonItem,达到重写backBarButtonItem的目的,但会造成右滑返回手势的响应代理从当前页面被覆盖性移除。同时,系统也提供了leftItemsSupplementBackButton属性来控制backBarButtonItem 是否被 leftBarButtonItem “覆盖”,默认值是NO,若配置leftBarButtonItem,还需要有返回按钮和右滑手势,需要在leftBarButtonItem或leftBarButtonItems后,把leftItemsSupplementBackButton,设置为YES。

特定页面停用右滑手势?

   如左右分页浏览、看视频、看音频、支付等特定页面场景,是“不希望”用户便捷离开的,或有弹窗提示的需求,也有避免用户误操作的考虑。同时,可能存在右滑返回手势冲突,或右滑返回后可能有音频焦点不能及时释放的问题。怎么做呢?我们可以通过代码设置停用右滑返回手势,或改用presentViewController方式加载页面。

恢复右滑手势的解决方案

方案一 手势代理替换

   系统的自带的有返回箭头和上级页面title的返回按钮,我们无需设置,系统自动生成,默认tintColor为蓝色。然而,这样的样式并不是我们想要的。我们通常做法是去,设置该页面的leftBarButtonItem或leftBarButtonItems,来自定义返回按钮的样式。通过上面的问题分析,我们可以知道,leftBarButtonItem或leftBarButtonItems 直接覆盖了self.navigationController.navigationBar.backItem.backBarButtonItem,造成右滑返回手势的响应代理从当前页面被覆盖性移除,造成右滑返回手势失效。我们可以通过在上个页面设置self.navigationItem.backBarButtonItem,并在下个页面设置self.navigationItem.leftItemsSupplementBackButton = YES。没有做基类管理的项目可能到处都是自定义leftBarButtonItem或leftBarButtonItem,工作量较大。快上车,让老司机带你一程!

保留系统的右滑返回手势

   既然设置backBarButtonItem较为繁杂,我们可以换个思路,手势已被覆盖性移除,我们需要给页面添加上右滑返回手势。若项目有全局的UINavigationController基类,实现下列参考代码:

@implementation YGNavigationController

- (void)viewDidLoad
{
    [super viewDidLoad];
    //设置右滑返回手势的代理为自身
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        self.interactivePopGestureRecognizer.delegate = (id)weakself;
    }
}


//这个方法是在手势将要激活前调用:返回YES允许右滑手势的激活,返回NO不允许右滑手势的激活
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    if (gestureRecognizer == self.interactivePopGestureRecognizer) {
        //屏蔽调用rootViewController的滑动返回手势,避免右滑返回手势引起死机问题
        if (self.viewControllers.count < 2 ||
 self.visibleViewController == [self.viewControllers objectAtIndex:0]) {
            return NO;
        }
    }
    //这里就是非右滑手势调用的方法啦,统一允许激活
    return YES;
}
复制代码

   将项目中的使用UINavigationController 替换为UINavigationController基类,自定义返回按钮设置不变,恢复了右滑返回手势。注意:导航栏的左侧也是支持右滑返回手势,若有UIViewController基类也可以参照上面设置代码调整设置,来消除导航栏的左侧小区域的右滑返回。

   一定要实现UIGestureRecognizerDelegate 并做rootViewController 判断,否则,在rootViewController页面会存在右滑返回死机的问题。

特定页面停用右滑手势

   我们查看UINavigationController 文档,可以找到

@property(nullable, nonatomic, readonly) UIGestureRecognizer *interactivePopGestureRecognizer NS_AVAILABLE_IOS(7_0) __TVOS_PROHIBITED;
复制代码

   可以通过设置页面的VC.navigationController.interactivePopGestureRecognizer.enabled 来控制当前页面的右滑返回手势是否可用。我们可以创建一个UIViewController 的分类创建两个类方法。

+ (void)popGestureClose:(UIViewController *)VC
{
    // 禁用侧滑返回手势
    if ([VC.navigationController respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        //这里对添加到右滑视图上的所有手势禁用
        for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = NO;
        }
        //若开启全屏右滑,不能再使用下面方法,请对数组进行处理
        //VC.navigationController.interactivePopGestureRecognizer.enabled = NO;
    }
}

+ (void)popGestureOpen:(UIViewController *)VC
{
    // 启用侧滑返回手势
    if ([VC.navigationController respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
    //这里对添加到右滑视图上的所有手势启用
        for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = YES;
        }
        //若开启全屏右滑,不能再使用下面方法,请对数组进行处理
        //VC.navigationController.interactivePopGestureRecognizer.enabled = YES;
    }
}
复制代码

   具体怎么使用呢?我们需要在停用右滑返回手势的页面实现以下两个方法,经过多次调试验证,必须是以下两个方法。停用当前页面后,不影响上级页面和下级页面的右滑返回。

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear:animated];
    [UIViewController popGestureClose:self];
}

- (void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    [UIViewController popGestureOpen:self];
}

复制代码

方案二 原生态:自定义backBarButtonItem

   网上的思路大多是基于方案一,这是我在研究方案一中回溯思路得出的一个方案,直接利用系统的backBarButtonItem和右滑返回手势特性,相对更稳定,更高效,我想iOS系统APP的右滑返回设计应是这个“官方思路”。

保留系统的右滑返回手势

   这里需要对每个页面设置自己的backBarButtonItem,就像设置每个页面的leftBarButtonItem的思路一样。但是backBarButtonItem是一个特殊的按钮,可以说只响应页面的返回和销毁,表现为只能自定义image和title,不能重写target 或 action。来让我们自定义以下backBarButtonItem。参照问题分析的思路,须在AViewController中实现下列参考代码:

    UIBarButtonItem *backItem = [[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil];
    //自定义返回按钮的视图,如细化返回图标。
     [self.navigationController.navigationBar setBackIndicatorImage:[UIImage imageNamed:@"navi_back_icon"]];
     [self.navigationController.navigationBar setBackIndicatorTransitionMaskImage:[UIImage imageNamed:@"navi_back_icon"]];
     //设置tintColor 改变自定图片颜色
     self.navigationController.navigationBar.tintColor = [UIColor whiteColor];
     //设置自定义的返回按钮
     self.navigationItem.backBarButtonItem = backItem;
复制代码

   按照上面的创建思路,已经完成页面自定义返回按钮,并保留了右滑返回手势(注意:导航栏的左侧是不只支持右滑返回手势,这里和方案一有一点区别)。在AViewController push BViewController 或 CViewController 都不需要在再重定义leftBarButtonItem,来实返回按钮了。依次实现各个控制器的backBarButtonItem,即可完成整个APP的右滑返回手势功能,当然以上代码我们可以封装到一个UIViewController基类并在ViewDidLoad方法中来统一设置,或者封装一个工具方法统一调用,当新的页面页面需要不同的返回样式时,在push页面CViewController之前,重新创建backBarButtonItem覆盖即可。    注意:因系统backBarButtonItem中封装的UIButton使用的左图右标题的布局样式和通常的UIButton上图下标题的布局样式有一定的差别,造成即使标题为空,返回按钮的图标的位置依然偏左,我们可以通过UIBarButtonItem的UIBarButtonSystemItemFixedSpace来调图标位置或者设置占位符标题增大手势响应区域。

特定页面停用右滑手势或左侧新添按钮

   怎么做呢?自定义leftBarButtonItem或leftBarButtonItems,并设置leftItemsSupplementBackButton = YES。参考代码:

 //自定义返回按钮
     UIButton *studySearch = [UIButton buttonWithType:UIButtonTypeCustom];
     [studySearch setImage:[UIImage imageNamed:@"study_search"] forState:UIControlStateNormal];
     [studySearch sizeToFit];
     [studySearch addTarget:self action:@selector(studySearchAction) forControlEvents:UIControlEventTouchUpInside];
    UIBarButtonItem *studySearchItem = [[UIBarButtonItem alloc] initWithCustomView:studySearch];
     self.navigationItem.leftBarButtonItems = @[studySearchItem];
     //是否支持显示左滑返回按钮,NO不显示:leftBarButtonItems覆盖backBarButtonItem,
     //YES显示:backBarButtonItem 显示在leftBarButtonItems左侧
     self.navigationItem.leftItemsSupplementBackButton = YES;
复制代码

   leftItemsSupplementBackButton必须在自定义leftBarButtonItem或leftBarButtonItems后才有效。

方案三 完全自定义导航栏

   有些项目中的导航栏或导航控制器是完全自定义的,具体的实现的可以参照方案一实施,这里不再做深入探究。

右滑返回引起手势的冲突

   方案二不会存在方案一中的卡死现象。iOS系统中,滑动返回手势其实是一个UIPanGestureRecognizer,UIScrollView的滑动手势也是UIPanGestureRecognizer,UIPanGestureRecognizer接收顺序和UIView的层次结构是一致的。

UINavigationController.view —>  UIViewController.view —>  UIScrollView —>  Screen and User's finger
复制代码

   原理:UIScrollView(包括子类UITextView、UITableView、UICollectionView)的panGestureRecognizer先接收到手势事件,直接处理后不在往下传递。实际上这就是两个panGestureRecognizer共存的问题。scrollView的pan手势会让系统的pan手势失效,当UIScrollView(UICollectionView)有多页的时候也会出现滑动返回失效的情况,我们需要在scrollView的位置在初始位置的时候,让两个手势同时启用。 可以创建UIScrollView的类别category,然后在此类别中实现以下方法即可:

@implementation UIScrollView (PopGesture)

//此方法返回YES时,手势事件会一直往下传递,不论当前层次是否对该事件进行响应。
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
    if ([self panBack:gestureRecognizer]) {
        return YES;
    }
    return NO;
}

//location_X可自己定义,其代表的是滑动返回距左边的有效长度
- (BOOL)panBack:(UIGestureRecognizer *)gestureRecognizer
{
    //是滑动返回距左边的有效长度
    int location_X = 40;
    if (gestureRecognizer == self.panGestureRecognizer) {
        UIPanGestureRecognizer *pan = (UIPanGestureRecognizer *)gestureRecognizer;
        CGPoint point = [pan translationInView:self];
        UIGestureRecognizerState state = gestureRecognizer.state;
        if (UIGestureRecognizerStateBegan == state || UIGestureRecognizerStatePossible == state) {
            CGPoint location = [gestureRecognizer locationInView:self];
            //下面的是只允许在第一张时滑动返回生效
            if (point.x > 0 && location.x < location_X && self.contentOffset.x <= 0) {
                return YES;
            }
         //   这是允许每张图片都可实现滑动返回
         //   int temp1 = location.x;
         //   int temp2 = SCREEN_WIDTH;
         //   NSInteger XX = temp1 % temp2;
         //   if (point.x > 0 && XX < location_X) {
         //      return YES;
         //   }
        }
    }
    return NO;
}

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
    if ([self panBack:gestureRecognizer]) {
        return NO;
    }
    return YES;
}

@end
复制代码

右滑返回的全屏幕设置

   随着手机屏幕的变大,原来右滑返回略显不够人性化,尤其是对手小的朋友,如何能愉快的单手玩手机呢。对于app要全屏右滑或保持原生边缘触发,各有说辞,这里不讨论其好坏,根据产品需要而定。我们在方案一的基础上,创建一个屏幕手势,添加到原来的self.interactivePopGestureRecognizer.view 右滑返回手势的视图上,即是讲手势添加到VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers数组中,添加手势必须在设置代理之前完成

- (void)viewDidLoad
{
    [super viewDidLoad];
    //设全屏启动右滑返回手势,此处可以优化为iPad 上支持全屏
    if ((UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad)) {
        id target = self.interactivePopGestureRecognizer.delegate;
        SEL handler = NSSelectorFromString(@"handleNavigationTransition:");
        // 获取添加系统边缘触发手势的View
        UIView *targetView = self.interactivePopGestureRecognizer.view;
        // 创建pan手势 作用范围是全屏
        UIPanGestureRecognizer *fullScreenGes = [[UIPanGestureRecognizer alloc]initWithTarget:target action:handler];
        fullScreenGes.delegate = self;
        [targetView addGestureRecognizer:fullScreenGes];
        // 关闭边缘触发手势 防止和原有边缘手势冲突(也可不用关闭)
        [self.interactivePopGestureRecognizer setEnabled:NO];
    }
    //设置右滑返回手势的代理为自身
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        self.interactivePopGestureRecognizer.delegate = (id)weakself;
    }
}
复制代码

   注意: 系统在self.interactivePopGestureRecognizer.view上已经添加有VC.navigationController.interactivePopGestureRecognizer手势,也可以在VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers数组中取出,此时数组中,有两个响应手势。因此对方案一中的手势控制就要使用数组形式的处理方式。

for (UIGestureRecognizer *popGesture in VC.navigationController.interactivePopGestureRecognizer.view.gestureRecognizers) {
            popGesture.enabled = NO;
        }
复制代码

总结

   iOS开发都是基于苹果系统的开发,设置系统级全局性的功能时,最好选择系统或在系统的基础上自定义,尽量少些自以为是的完全自定义,少些奇葩设计,好的内容才是一个产品的核心,好的产品体验也是用户留存的粘合剂!

原文

Objective-C Runtime使用之全局字体替换为第三方字体

From: https://www.cnblogs.com/n1ckyxu/p/6084544.html

前言:

  iOS开发里头,常用的设置字体方式是使用UIFont的systemFontOfSize这个Class Method,在一半情况下都算够用。

最近有设计师朋友问能不能在客户端中使用特定的字体,答案是可以的,我们可以通过手动给工程添加配置字体的ttf文件(字体库)

然后通过fontWithName:name size:size这个 Class Method即可选用,然而在一个已经经过长时间开发的客户端,会有历史遗漏问题

导致整个工程的字体配置可能存在修改工作量大,改漏改错等情况,针对这种情况我们也可以通过runtime来解决。

1、导入第三方字体

首先需要下载一个.ttf为后缀的文件,也就是字体库。下载后将文件导入工程,如图

接着需要在工程配置info.plist中添加这个字体

在info.plist中添加一行,key是Fonts provided by application,中文意思即 字体由应用程序提供

这是个array对象,那么我们把它展开

往里面添加一个item,内容即我们刚刚添加的那个文件名

然后在Build Phases里添加资源文件 如图

接下来可以在工程中,通过UIFont 这个类 遍历我们现在可以用的字体集和字体名字

遍历代码如下

NSArray *fontFamilys = [UIFont familyNames];
for (NSString *familyName in fontFamilys) {
    NSLog(@"family name : %@",familyName);
    NSArray *fontNames = [UIFont fontNamesForFamilyName:familyName];
    for (NSString *fontName in fontNames) {
        NSLog(@"font name : %@",fontName);
    }
}

注意 ,不同的iOS大版本之间,可使用的字体库会有差异,但是我们这里只需要取到我们手动添加的字体

遍历出来的内容很多,不翻页也不好找到我们添加的字体。

我这里添加的字体是微软雅黑,那么我搜一下

也是可以找到的,这里我们需要取font name,即图上的2016-11-21 09:49:45.780 FontDemo[17853:921926] font name : MicrosoftYaHei

取到字体名字,我们就可以通过

[UIFont fontWithName:@"MicrosoftYaHei" size:16];

fontWithName: size: 这个类方法去得到我们需要的UIFont对象,也就是雅黑字体

——————————不华丽的分割线————————–

好了,单个字体的更换这里是实现了,但是我这里需要的是全局的字体修改

接下来的内容又要接触到objc runtime 的method exchange了,也就是method swizzling

在Objective-c中,hook方案能解决很多问题,这里的问题是其中之一

但是这种全局设置的方法交换也有一定的局限性,比如 我需要再换其他字体呢? 这个问题后面再探讨

开始设置method swizzling

首先 建立一个UIFont的categroy

在.m文件中 实现load方法,并调用父类load

+ (void)load{
    [super load];
}

接着 做method swizzling的过程 只需要调用一次,

那么可以用gcd的once 执行,

+ (void)load{
    [super load];
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method oldMethod = class_getClassMethod([self class], @selector(systemFontOfSize:));
        Method newMethod = class_getClassMethod([self class], @selector(__nickyfontchanger_YaheiFontOfSize:));
        method_exchangeImplementations(oldMethod, newMethod);
    });
}

别忘了#import

解析一下上面这几句代码

首先Method即方法,class_getClassMethod这是获取类方法,因为我们原来使用的systemFontOfSize是个类方法。

如果要交换的是实例方法,那么就要用class_getInstanceMethod 获取

先获取旧的方法,再获取新的方法,新的方法是写在这个category里的

像我这里:

+ (UIFont *)__nickyfontchanger_YaheiFontOfSize:(CGFloat)fontSize{
    UIFont *font = [UIFont fontWithName:@"MicrosoftYaHei" size:fontSize];
    if (!font)return [self __nickyfontchanger_YaheiFontOfSize:fontSize];
    return font;
}

再来解析一下这个方法的执行:

首先获取我们的第三方字体,若字体不存在,则返回系统默认字体

但是为什么我返回系统默认字体的时候,调用的是 [self __nickyfontchanger_YaheiFontOfSize:fontSize]呢?

因为方法已经交换了,实际上这个方法的pointer指向的是系统的systemFontOfSize这个方法

具体的实现

那么再运行一下工程看看?

ps:问题来了

我要单独给某个字体设置成系统字体怎么办?

事实上我们这里只是把两个方法交换了而已,所以我们只要把+ (UIFont *)__nickyfontchanger_YaheiFontOfSize:(CGFloat)fontSize;这个方法写到.h的声明里面即可,它实际就是系统字体

如有错误欢迎更正