博客更新日志
2018年3月16日 更新:消息转发逻辑,放弃了之前的代理方法转发方式,改用方法重定向实现多代理消息分发;更改了部分说明。
一、痛点
我们在业务开发中,往往会遇到需要限制文本输入的需求,比如只能输入数字、不能输入空格,稍微复杂一点的比如小数点后最多两位的价格输入。当然,若你的正则表达式玩儿得很溜,这些并不是难题。但是我们仍然需要设置代理、实现代理,然后写上一堆的判断逻辑,总是有一些奇奇怪怪的问题导致最终结果不能很快完美呈现。
于是,我写下这篇文章,总结一下关于UITextField和UITextView输入控制的那些事儿,并且还献上一个框架。
该框架在挺久之前就已经做出来了,发出来过后有些朋友挺感兴趣,但是就是bug比较多。所以这些天重构了一下,修复了很多问题,优化了体验。
二、解决办法
对于UITextField监听文本变化的方式一般分为两种,一种是输入已经绘制到界面上之后,一种是还未绘制之前。
之后
[textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged]; - (void)textChange:(id)obj { NSLog(@"%@", [obj valueForKey:@"text"]); }
对于这种方法,我们能对已经绘制到textfield的文本进行一些逻辑判断,经过替换、移除、截取等操作就能实现对文本的控制。
当我们设定了某些不能输入的字符,就需要查找出来移除,然后若对长度有要求,还得再次判断,字符串替换过程有些复杂,而且还会造成不可控的字符改变(用户可能是无意识的)。
之前
textfield.delegate = self; - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string { //计算如果允许输入的结果字符串 NSString *nowStr = [textField valueForKey:@"text"]; NSMutableString *resultStr = [NSMutableString stringWithString:nowStr]; if (string.length == 0) { //删除 [resultStr deleteCharactersInRange:range]; } else { if (range.length == 0) { //插入 [resultStr insertString:string atIndex:range.location]; } else { //替换 [resultStr replaceCharactersInRange:range withString:string]; } } //根据拿到的 resultStr 判断是否包含非法字符,是否超长(可使用正则表达式处理) ...... }
这种方式就是在文本绘制之前会走的代理方法,我们可以在里面将非法字符扼杀在摇篮中。
提前监听在使用索引功能时弊端
但是在处理带索引输入的时候,会出现下图情况:
看到了么,我们此刻是输入中文,而被选中的字符(也就是我们的拼音)已经输入在了textFiled里面,它仍然会走textField: shouldChangeCharactersInRange: replacementString:代理方法和- (void)textChange:(id)obj回调。
以下两种情况,在代理方法里面处理会出现问题:
在这里判断了长度:比如限制最多输入8个字符,我们还想在打几个拼音就会看到textFiled里面文本内容不会增加了,也就是无法继续输入,因为此时jian shu已经占了8个字符,而我们可能是想输入8个汉字。
在这里限制了非法字符:比如在该代理方法限制空格为非法字符,那么在输入到jian s的时候,就会出现点击无反应,因为此时已经有非法字符出现,文本不允许录入。而当我们想要退格的时候,发现仍然不能动,此刻已经是非法状态。
所以,这种情况只能在上述的 [textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged];方式处理。代码大致如下:
- (void)textChange:(id)obj { //无选中字符情况 if ([obj valueForKey:@"markedTextRange"] == nil) { NSString *currentText = [obj valueForKey:@"text"]; //去除非法字符-空格 if ([currentText containsString:@" "]) { currentText = [currentText stringByReplacingOccurrencesOfString:@" " withString:@""]; } //判断是否超长 if (currentText.length > 8) { [obj setValue:[currentText substringToIndex:8] forKey:@"text"]; } else { [obj setValue:currentText forKey:@"text"]; } } }
点击索引字符不走代理监听方法
就在上图中,若我们点击索引栏的建树等字符时,textField会直接绘制,而此刻发现textField: shouldChangeCharactersInRange: replacementString:代理方法没有回调(在使用索引输入英文单词时一样)。
这种情况我们就得按照业务需求处理。
若需要输入英文或者中午的描述性字符的时候,一般做的非法字符限制比较少,更多的是做长度限制,就使用[textfield addTarget:self action:@selector(textChange:) forControlEvents:UIControlEventEditingChanged];方式处理(点击索引字符会走该方法)。
若只能输入英文、特殊字符、数字等,就将键盘的索引关掉,并且将键盘种类更改,让用户不能切换到中文键盘(因为中文键盘自带索引,关不掉),方法如下:
//关索引 tf.autocorrectionType = UITextAutocorrectionTypeNo; //换键盘 tf..keyboardType = UIKeyboardTypeASCIICapable;
UITextView 的处理方法和 UITextField 的处理差不多,这里就不在赘述。
结论
由此可见,对文本输入的控制需要在两种监听文本输入方法间灵活处理,为了提高开发效率,本人对其做了封装,下面解释一下YBInputControl框架的设计思路和设计模式。
三、YBInputControl 框架解读(难点是方法重定向)
首先,为了减少耦合,使用了分类的方式,给UITextField和UITextView添加了一个属性:
@interface UITextField (YBInputControl) @property (nonatomic, strong, nullable) YBInputControlProfile *yb_inputCP; @end @interface UITextView (YBInputControl) @property (nonatomic, strong, nullable) YBInputControlProfile *yb_inputCP; @end
YBInputControlProfile类包含了一系列的配置:
/** 限制输入长度,NSUIntegerMax表示不限制(默认不限制) */ @property (nonatomic, assign) NSUInteger maxLength; /** 限制输入的文本类型(单选,在内部其实是配置了regularStr属性) */ @property (nonatomic, assign) YBTextControlType textControlType; /** 限制输入的正则表达式字符串 */ @property (nonatomic, copy, nullable) NSString *regularStr; /** 文本变化回调(observer为UITextFiled或UITextView)*/ @property (nonatomic, copy, nullable) void(^textChanged)(id observe); /** 添加文本变化监听 */ - (void)addTargetOfTextChange:(id)target action:(SEL)action; ......
当然,现在你不用知道内部实现,从结构的设计来看,应该很轻松的想到使用方法就是给 yb_inputCP 属性赋值,YBInputControlProfile类包含了诸如长度、文本限制类型、直接输入正则表达式,文本变化回调等,文本现在类型目前加的不多,大概观感是这样的:
typedef NS_ENUM(NSInteger, YBTextControlType) { YBTextControlType_none, //无限制 YBTextControlType_number, //数字 YBTextControlType_letter, //字母(包含大小写) YBTextControlType_letterSmall, //小写字母 YBTextControlType_letterBig, //大写字母 YBTextControlType_number_letterSmall, //数字+小写字母 YBTextControlType_number_letterBig, //数字+大写字母 YBTextControlType_number_letter, //数字+字母 YBTextControlType_excludeInvisible, //去除不可见字符(包括空格、制表符、换页符等) YBTextControlType_price, //价格(小数点后最多输入两位) };
这里我也考虑过使用多选枚举处理,但是后来发现使用体验并不好,所以还是搞成单选,多列举一些也不碍事。
大致的结构就是这样,很简单,下面解析一下内部实现(主要实现 UITextField 和 UITextView 差不多)。
UITextField分类中yb_inputCP的getter和setter实现如下:
- (void)setYb_inputCP:(YBInputControlProfile *)yb_inputCP { @synchronized(self) { if (yb_inputCP && [yb_inputCP isKindOfClass:YBInputControlProfile.self]) { objc_setAssociatedObject(self, key_Profile, yb_inputCP, OBJC_ASSOCIATION_RETAIN); self.delegate = self; self.keyboardType = yb_inputCP.keyboardType; self.autocorrectionType = yb_inputCP.autocorrectionType; yb_inputCP.textChangeInvocation || yb_inputCP.textChanged ? [self addTarget:self action:@selector(textFieldDidChange:) forControlEvents : UIControlEventEditingChanged]:nil; } else { objc_setAssociatedObject(self, key_Profile, nil, OBJC_ASSOCIATION_RETAIN); } } } - (YBInputControlProfile *)yb_inputCP { return objc_getAssociatedObject(self, key_Profile); }
代码逻辑很简单,既是对当前textFiled关联一个yb_inputCP属性,并且将代理设为自己self.delegate = self;,其实到这里大概也能猜到,该框架主要是通过分类里面的代理回调做功能。
但是有一个问题值得注意,框架是通过接收来自UITextFieldDelegate代理的方法,如果使用者在外部也想要获取某些代理回调怎么办,如果不采用特殊处理,要么框架功能失效,要么使用者懵逼为何拿不到回调。
所以,接下来要讲解的是重点思想。
方法重定向
首先,我大概说明一下OC中给一个对象发送消息是个什么过程:
遍历当前类的方法列表,找到该方法并且执行IMP方法体(有缓存机制提高查找效率)。
如果没找到该方法,runtime会尝试在+resolveInstanceMethod: 或者 +resolveClassMethod:中处理该方法。若方法返回YES,runtime会重新尝试发送这个消息。
若+resolve...方法返回NO,runtime会走-forwardingTargetForSelector:方法允许你返回一个方法接受者(意味着可以更改方法接受者)。
若-forwardingTargetForSelector:方法没有对象返回,runtime会走methodSignatureForSelector:方法尝试获取一个方法体对象(NSMethodSignature),若该方法没有有效的返回值,就会报异常unrecognized selector sent to instance。
若methodSignatureForSelector:方法返回了一个有效的方法体,runtime会走-forwardInvocation:方法尝试发送消息,当然这里也可以使用-doesNotRecognizeSelector:方法抛出异常。
现在,框架需要做的事情是让内部和外部能同时获取到代理回调,也就是要做到多代理消息分发。目前可以考虑的是:
第一,在-forwardingTargetForSelector:方法中处理,但是该方法只支持对一个对象的消息转发。
第二,在-forwardInvocation:方法中处理,里面可以给任意对象发送消息,显然,这正是我们需要的。
方法重定向实现多代理消息分发
ps:之前使用的是繁琐的代理方法转发方式,不够优雅,而使用方法重定向的方式做明细优雅很多。
结合到框架的业务需求,这里本人考虑的是使用一个中间代理类作为textFiled.delegate,如下:
@interface YBInputControlTempDelegate : NSObject @property (nonatomic, weak) id delegate_inside; @property (nonatomic, weak) id delegate_outside; @property (nonatomic, strong) Protocol *protocol; @end
delegate_inside即为textFiled自身,delegate_outside即为使用者自己在外部设置的代理:textFiled.delegate = anyInstace,protocol为代理对象,中间某个环节需要用到这个runtime层面的实例。
看到这里,会想到何时将textFiled的代理设置为这个中间代理YBInputControlTempDelegate呢?代码如下:
+ (void)load { if ([NSStringFromClass(self) isEqualToString:@"UITextField"]) { Method m1 = class_getInstanceMethod(self, @selector(setDelegate:)); Method m2 = class_getInstanceMethod(self, @selector(customSetDelegate:)); if (m1 && m2) { method_exchangeImplementations(m1, m2); } } } - (void)customSetDelegate:(id)delegate { @synchronized(self) { if (objc_getAssociatedObject(self, key_Profile)) { YBInputControlTempDelegate *tempDelegate = [YBInputControlTempDelegate new]; tempDelegate.delegate_inside = self; if (delegate != self) { tempDelegate.delegate_outside = delegate; } [self customSetDelegate:tempDelegate]; objc_setAssociatedObject(self, key_tempDelegate, tempDelegate, OBJC_ASSOCIATION_RETAIN); } else { [self customSetDelegate:delegate]; } } }
这里的核心逻辑就是 textFiled.delegate= tempDelegate。只要你使用该框架给当前textFiled赋值了配置属性yb_inputCP,就说明你是想要使用该框架的功能的,那么接下来你的setDelegate:操作都会被我“移花接木”,值得注意的是objc_setAssociatedObject(self, key_tempDelegate, tempDelegate, OBJC_ASSOCIATION_RETAIN);这句代码必不可少,否则YBInputControlTempDelegate实例会在该次runloop循环结束时释放。
现在基础设施都配置好了,剩下的就是写消息转发的逻辑了,这些逻辑都是在YBInputControlTempDelegate类里面。
首先,需要重写respondsToSelector:方法:
- (BOOL)respondsToSelector:(SEL)aSelector { struct objc_method_description des = protocol_getMethodDescription(self.protocol, aSelector, NO, YES); if (des.types == NULL) { return [super respondsToSelector:aSelector]; } if ([self.delegate_inside respondsToSelector:aSelector] || [self.delegate_outside respondsToSelector:aSelector]) { return YES; } return [super respondsToSelector:aSelector]; }
第一步通过protocol_getMethodDescription()判断aSelector是否是我们需要转发的代理,若不是,那么继续走默认逻辑,若是,就判断实际需要回调的两个对象self.delegate_inside和self.delegate_outside是否实现了当前方法,若其中有一个实现了,都返回YES。
然后,就是做具体的消息转发逻辑了:
- (void)forwardInvocation:(NSInvocation *)anInvocation { SEL sel = anInvocation.selector; BOOL isResponds = NO; if ([self.delegate_inside respondsToSelector:sel]) { isResponds = YES; [anInvocation invokeWithTarget:self.delegate_inside]; } if ([self.delegate_outside respondsToSelector:sel]) { isResponds = YES; [anInvocation invokeWithTarget:self.delegate_outside]; } if (!isResponds) { [self doesNotRecognizeSelector:sel]; } } - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { NSMethodSignature *sig_inside = [self.delegate_inside methodSignatureForSelector:aSelector]; NSMethodSignature *sig_outside = [self.delegate_outside methodSignatureForSelector:aSelector]; NSMethodSignature *result_sig = sig_inside?:sig_outside?:nil; return result_sig; }
YBInputControlTempDelegate类里面没有实现UITextFieldDelegate代理的任何方法,从而所有的代理方法都可以分发出去。接下来只需要在@implementation UITextField (YBInputControl)实现部分做该框架的核心逻辑就OK了:
- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string { return yb_shouldChangeCharactersIn(textField, range, string); } - (void)textFieldDidChange:(UITextField *)textField { yb_textDidChange(textField); }
特别注意:有些代理方法是有返回值的,比如textField: shouldChangeCharactersInRange: replacementString:方法,在框架的延展里面需要做逻辑,然后返回一个BOOL值判断是否可以输入,若外部也监听了该代理方法,实际上发送该消息整个逻辑完成过后,返回的是更后面的那个返回值,也就是[anInvocation invokeWithTarget:self.delegate_outside];的返回值,也就是外部使用者写的返回值,这就导致了框架内部的功能失效。(解决方法在github里面有讲,只是在对应方法调用一下框架方法就行了)
UITextView不能使用该方案
其实,采用这种处理办法可能会带来某些隐患。
UITextField的代理是@protocol UITextFieldDelegate
况且,UITextField的父类是UIControl,向上追溯也没有类带有delegate属性,也就是说,UITextField的setDelegate:方法实现中理论上是没有关于父类同样delegate属性和代理方法的处理。
在UITextView中,没有使用这种方法。
看@protocol UITextViewDelegate
而且其父类是UIScrollView,UIScrollView中有着delegate属性,在UITextView的setDelegate:中肯定会有着对父类代理的操作,这里面的逻辑不得而知,所以这里不能使用代理转接的思路强行插入逻辑(做过测验,UITextView这么做运行中会有一些中间类找不到setDelegate:方法而崩溃,具体原因还没来得及探究)。
四、尾声
总的来说,该小框架的核心功能很简单,但是为了少改动使用者以往的习惯,使用了方法重定向实现多代理分发(包括之前不那么优雅的代理方法转发),提高了使用者的接受度。这当中使用到了runtime的几个方法和处理了方法调用周期,从技术上说不算难,但是为了实现某个需求而深入探究本质将这些点结合起来,就不是一件容易的事。
本文主要讲解了一种解决问题的思路,为了提高一点用户体验度而大费周章的做技术上的功课,这正是写代码给别人用与写代码给自己用的区别,谨以此文抛砖引玉,欢迎大家一起交流。
作者:indulge_in
链接:https://www.jianshu.com/p/0e527df5c1ef