Featured image of post runtime实战系列(一)--- 微信通讯录bug的完美解决

runtime实战系列(一)--- 微信通讯录bug的完美解决

某天,闲来无聊点击微信通讯录右边的索引(具体操作如下:打开微信—通讯录,然后右手点击某个索引,手指别松开,然后左手点击某个联系人cell,跳转后再返回,你刚才长按的放大索引还在,并且再滑动,索引对应字母也不变化,索引视图卡住。),发现了这个bug(我都滑动到L了,放大的索引还是M),如下:

微信通讯录bug.gif

由于自己的项目也有类似的功能,同样操作,也有同样的bug,于是就赶紧修复一下。

问题有了,解决问题的关键就是找到原因并给出合理的解决方案。

Why?

Why?

Why?

原因:放大的索引视图在显示的情况下点击tableViewCell没有隐藏掉.

解决方案:didSelectRowAtIndexPath方法里隐藏(or移除)放大了的索引视图。

  • 方案一:用通知,didSelectRowAtIndexPath调用的时候给**PPSectionTitleIndexView**发个通知。
  • 方案二 :用runtimedidSelectRowAtIndexPath调用的时候给UItableView添加一个block,**PPSectionTitleIndexView**里面的tableView实现block

两种方案对比:

方案一: 需要每次调用didSelectRowAtIndexPath时都要发送一个通知;

方案二: 什么也不用做。所以,当然方案二更好。

扯了那么多,现在进入实战。

第一步 :创建PPSectionTitleIndexView继承自UIView,并声明代理PPSectionTitleIndexViewDelegate,代码如下:

 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
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@protocol PPSectionTitleIndexViewDelegate <NSObject>

@required
/**
 与PPSectionTitleIndexView相关联的view(UITableView)
 */
-(UITableView *)sectionTitleIndexViewAssociatedView;

/**
 section的titles
 */
-(NSArray<NSString *> *)sectionTitleIndexViewSectionTitles;

@end

@interface PPSectionTitleIndexView : UIView
/** 索引是否正在显示 */
@property(nonatomic,assign,readonly) BOOL isShowingIndex;

//这三种不能用于初始化
-(instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
-(instancetype)init NS_UNAVAILABLE;
+(instancetype)new NS_UNAVAILABLE;

-(instancetype)initWithFrame:(CGRect)frame delegate:(_Nullable id<PPSectionTitleIndexViewDelegate>)delegate NS_DESIGNATED_INITIALIZER;

+(instancetype)pp_sectionTitleIndexViewWithFrame:(CGRect)frame delegate:(_Nullable id<PPSectionTitleIndexViewDelegate>)delegate;
@end

NS_ASSUME_NONNULL_END

第二步:设置PPSectionTitleIndexView

思路:每个索引用UIButton,当前点击的放大索引用UILabel显示。

核心代码如下:

1.创建UI

 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
38
39
-(instancetype)initWithFrame:(CGRect)frame delegate:(id<PPSectionTitleIndexViewDelegate>)delegate
{
    self = [super initWithFrame:frame];
    if (self) {
        self.delegate = delegate;
        [self creatUI];

    }
    return self;
}

-(void)creatUI
{
    //1. 先检验必须实现的协议有没有实现
    [self verifyRequiredProtocol];
    //2. 创建索引UI
    [self creatIndexUI];
    //3. 添加平移手势
    [self addPanGesture];
}

//此处只贴出来index的button创建代码
 for (int i = 0; i<self.indexTitles.count; i++) {
        UIButton *item = [UIButton buttonWithType:UIButtonTypeCustom];
        [self addSubview:item];
        item.tag = 100+i;
        item.frame = CGRectMake(0, topOrBottomMargin+(oneIndexWidth+itemMargin)*i, indexVWidth, oneIndexWidth);
        
        item.titleEdgeInsets = UIEdgeInsetsMake(0, self.frame.size.width-oneIndexWidth-5, 0, 5);
        [item setTitle:self.indexTitles[i] forState:UIControlStateNormal];
        [item setTitle:self.indexTitles[i] forState:UIControlStateHighlighted];
        [item setTitleColor:[UIColor blueColor] forState:UIControlStateNormal];
        [item setTitleColor:[UIColor blueColor] forState:UIControlStateHighlighted];
        item.backgroundColor = [UIColor clearColor];
        item.titleLabel.font = [UIFont systemFontOfSize:12];
        
        [item addTarget:self action:@selector(btnClickedDown:) forControlEvents:UIControlEventTouchDown]; //只要点击
        [item addTarget:self action:@selector(btnTouchUpInside:) forControlEvents:UIControlEventTouchUpInside]; //点击松手
    }

2.响应事件的

 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
38
39
40
41
42
43
44
45
46
47
-(void)btnClickedDown:(UIButton *)sender
{
    //1. 获取当前sectionTitle的frame
    CGRect currentSectionTitleRect = [self.tableView rectForSection:sender.tag-100];
    //2. 把tableView当前section滚动到top (不要动画效果更好)
    [self.tableView setContentOffset:CGPointMake(currentSectionTitleRect.origin.x, currentSectionTitleRect.origin.y) animated:NO];
    //3. 把当前的index放大显示到屏幕中间
    NSString *currentIndexStr = sender.titleLabel.text;
    self.currentIndexShowLB.text = currentIndexStr;
    //4. 处理indexStr
    if (currentIndexStr.length == 1) {
        self.currentIndexShowLB.font = [UIFont systemFontOfSize:35];
    }else{
        self.currentIndexShowLB.font = [UIFont systemFontOfSize:18];
    }
    //5. 屏幕中间显示当前index
    self.currentIndexShowLB.hidden = NO;
    
}
-(void)btnTouchUpInside:(UIButton *)sender
{
    //该方法是在btnClickedDown后执行
    self.currentIndexShowLB.hidden = YES;
}
#pragma mark --- 添加平移手势
-(void)addPanGesture
{
    UIPanGestureRecognizer *panGR = [[UIPanGestureRecognizer alloc]initWithTarget:self action:@selector(panAction:)];
    [self addGestureRecognizer:panGR];
}
-(void)panAction:(UIPanGestureRecognizer *)panGR
{
    //1. 获取手指当前位置的point
    CGPoint fingerTapPoint = [panGR locationInView:self];
    //2. 遍历self上的所有自视图(UIButton *),如果它的tag和上次不一样,执行touchDown方法
    for (UIView *aV in self.subviews) {
        if ([aV isKindOfClass:[UIButton class]] && aV.tag != _selectedBtnTag && CGRectContainsPoint(aV.frame, fingerTapPoint)) {
            _selectedBtnTag = aV.tag;
            [self btnClickedDown:(UIButton *)aV];
        }
    }
    
    //3. 手势结束时,隐藏屏幕中间的showIndexLB
    if (panGR.state == UIGestureRecognizerStateEnded) {
        self.currentIndexShowLB.hidden = YES;
    }
}

至此,想要的功能算是实现了,但是文章一开始提到的bug也出现了,😜。

第三步:解决Bug

关键:tableViewCategory

runtime方案的实现步骤如下:

  1. 拦截UITableView的系统setDelegate,用pp_setDelegate替换;
  2. 判断delegate是否响应系统的tableView:didSelectRowAtIndexPath: ,如果响应才能继续3
  3. 根据系统的代理tableView:didSelectRowAtIndexPath: 方法和对应的指针给代理类[delegate class]利用runtimeclass_addMethod添加一个didSelected方法@selector(pp_fakeTableView:didSelectRowAtIndexPath:),并替换为pp_tableView:didSelectRowAtIndexPath:.
  4. 利用NSInvocation在替换的didSelected方法pp_tableView:didSelectRowAtIndexPath:中调用pp_fakeTableView:didSelectRowAtIndexPath:,因为pp_fakeTableView:didSelectRowAtIndexPath:和系统的tableView:didSelectRowAtIndexPath:指针指向相同,所以就相当于调用系统的tableView:didSelectRowAtIndexPath:,这样就实现了我们的需求。

我怕我说明的不清楚,特意画了个图:

代码如下:

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
///**
// * 通过给定的方法名和实现,动态给类添加一个方法
// *
// * @param cls   要添加方法的类.
// * @param name  指定了方法名的要添加到类的方法.
// * @param imp   添加方法的函数实现(函数地址).
// * @param types 函数的类型,(返回值+参数类型).
// *
// * @return      如果添加成功就返回YES,失败返回NO(如果该类中已经存在一个相同的方法名的方法实现).
// *
// * @note        注: class_addMethod将重写父类的实现,但是不会替换该类中已经有的实现,
//                如果想改变该类中已有的实现,请使用method_setImplementation
// */
//OBJC_EXPORT BOOL class_addMethod(Class cls, SEL name, IMP imp,const char * types)

-(void)pp_setDelegate:(id<UITableViewDelegate>)delegate
{
    if(delegate){
        //系统原生的didSelected方法
        SEL systemDidSelectedSelector = @selector(tableView:didSelectRowAtIndexPath:);
        //我自己重写的didSelected方法(用于替换系统的)
        SEL ppDidSelectedSelector     = @selector(pp_tableView:didSelectRowAtIndexPath:);
        //VIP:假的didSelected方法(和系统原生的didSelected方法指针地址相同,但不是一个方法,却响应同样事件)
        SEL fakeDidSelectedSelector   = @selector(pp_fakeTableView:didSelectRowAtIndexPath:);

        if ([delegate respondsToSelector:systemDidSelectedSelector]) {
            
            //系统的代理didSelected方法和对应的指针
            Method systemDidSelectedMethod = class_getInstanceMethod([delegate class], @selector(tableView:didSelectRowAtIndexPath:));
            IMP systemDidSelectedMethodIMP = method_getImplementation(systemDidSelectedMethod);
            
            //VIP:此处给系统原生的didSelected方法上添加新的方法(只要方法名不一样,就可以成功,详见系统api中的@return说明)
            //注意此方法放置位置,不能放在下面的class_replaceMethod后面(因为已经被替换了,指针会指向tableView *的pp_tableView:didSelectRowAtIndexPath:)
            class_addMethod([delegate class], fakeDidSelectedSelector, systemDidSelectedMethodIMP, method_getTypeEncoding(systemDidSelectedMethod));

            //自己重写的didSelected,用来处理拦截后想做的事情(比如:发通知,block回调等)
            Method ppDidSelectedMethod = class_getInstanceMethod([self class], ppDidSelectedSelector);
            IMP ppDidSelectedMethodIMP = method_getImplementation(ppDidSelectedMethod);
            
            //用自己重写的替换系统原生的
            class_replaceMethod([delegate class], systemDidSelectedSelector, ppDidSelectedMethodIMP, method_getTypeEncoding(systemDidSelectedMethod));
        }
    }
    //拦截原生的delegate,别忘了调用(此处调用pp_setDelegate:实际上就是调用setDelegate:,说句不该说的话,此处看不懂,runtime你根本不会)
    [self pp_setDelegate:delegate];
}

-(void)pp_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    //方案(通知)都用的到的通知,很显然太费事,不方便
    /*
    NSNotification *not = [[NSNotification alloc]initWithName:PPTableViewDidSelectedNotificationKey object:nil userInfo:nil];
    [[NSNotificationCenter defaultCenter]postNotification:not];
     */
    
    //方案(runtime):给tableView添加点击block,。。。。嗯,有没有想到更多??简化所有的delegate与dataSource方法,此处不说太多
    if (tableView.pp_didSelectedBlock) {
        tableView.pp_didSelectedBlock(tableView, indexPath);
    }
    
    //VIP: 此时,系统原生的didSelected方法已经被拦截,并且做了你想做的事情,可是怎么让系统原生的didSelected还能响应点击?
    /*
     方案一: 不用class_addMethod方法,而是在[delegate respondsToSelector:systemDidSelectedSelector]条件语句里
     添加代理绑定:
     objc_setAssociatedObject(PPTableViewDidSelectedNotificationKey, @selector(pp_tableView:didSelectRowAtIndexPath:), delegate, OBJC_ASSOCIATION_RETAIN);
     然后在此处,执行:
     id ppDelegate = objc_getAssociatedObject(PPTableViewDidSelectedNotificationKey, _cmd);
     [ppDelegate pp_tableView:tableView didSelectRowAtIndexPath:indexPath];
     哦😯,傻了,这相当于直接调用系统的didSelected方法,/(ㄒoㄒ)/~~,错!错!错!
     */
    
    //方案二:利用NSInvocation底层发消息,如下:
    SEL fakeDidSelectedSelector = @selector(pp_fakeTableView:didSelectRowAtIndexPath:);
    NSMethodSignature *methodSignature = [[tableView.delegate class]instanceMethodSignatureForSelector:fakeDidSelectedSelector];
    if (methodSignature == nil) {
        //可以抛出异常也可以不操作。
    }
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
    invocation.target = tableView.delegate;
    invocation.selector = fakeDidSelectedSelector;
    [invocation retainArguments];
    [invocation invoke];
}

2017年最后一天了,一首歌共勉:

Licensed under CC BY-NC-SA 4.0
Built with Hugo
主题 StackJimmy 设计