[关闭]
@chinese-ppmt 2018-04-16T05:17:57.000000Z 字数 8218 阅读 1772

runtime实战系列<一>微信通讯录bug的解决方案

runtime


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

微信通讯录bug.gif

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

问题有了,解决问题的关键就是找到原因并给出合理的解决方案。
Why? 一问
Why? 二问
Why? 三问
原因:放大的索引视图在显示的情况下点击tableViewCell没有隐藏掉.
解决方案:didSelectRowAtIndexPath方法里隐藏(or移除)放大了的索引视图。

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

两种方案对比:
方案一 需要每次调用didSelectRowAtIndexPath时都要发送一个通知;
方案二 什么也不用做。所以,当然方案二更好。

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

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

  1. #import <UIKit/UIKit.h>
  2. NS_ASSUME_NONNULL_BEGIN
  3. @protocol PPSectionTitleIndexViewDelegate <NSObject>
  4. @required
  5. /**
  6. 与PPSectionTitleIndexView相关联的view(UITableView)
  7. */
  8. -(UITableView *)sectionTitleIndexViewAssociatedView;
  9. /**
  10. section的titles
  11. */
  12. -(NSArray<NSString *> *)sectionTitleIndexViewSectionTitles;
  13. @end
  14. @interface PPSectionTitleIndexView : UIView
  15. /** 索引是否正在显示 */
  16. @property(nonatomic,assign,readonly) BOOL isShowingIndex;
  17. //这三种不能用于初始化
  18. -(instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
  19. -(instancetype)init NS_UNAVAILABLE;
  20. +(instancetype)new NS_UNAVAILABLE;
  21. -(instancetype)initWithFrame:(CGRect)frame delegate:(_Nullable id<PPSectionTitleIndexViewDelegate>)delegate NS_DESIGNATED_INITIALIZER;
  22. +(instancetype)pp_sectionTitleIndexViewWithFrame:(CGRect)frame delegate:(_Nullable id<PPSectionTitleIndexViewDelegate>)delegate;
  23. @end
  24. NS_ASSUME_NONNULL_END

第二步:设置PPSectionTitleIndexView

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

核心代码如下:

1.创建UI的

  1. -(instancetype)initWithFrame:(CGRect)frame delegate:(id<PPSectionTitleIndexViewDelegate>)delegate
  2. {
  3. self = [super initWithFrame:frame];
  4. if (self) {
  5. self.delegate = delegate;
  6. [self creatUI];
  7. }
  8. return self;
  9. }
  10. -(void)creatUI
  11. {
  12. //1. 先检验必须实现的协议有没有实现
  13. [self verifyRequiredProtocol];
  14. //2. 创建索引UI
  15. [self creatIndexUI];
  16. //3. 添加平移手势
  17. [self addPanGesture];
  18. }
  19. //此处只贴出来index的button创建代码
  20. for (int i = 0; i<self.indexTitles.count; i++) {
  21. UIButton *item = [UIButton buttonWithType:UIButtonTypeCustom];
  22. [self addSubview:item];
  23. item.tag = 100+i;
  24. item.frame = CGRectMake(0, topOrBottomMargin+(oneIndexWidth+itemMargin)*i, indexVWidth, oneIndexWidth);
  25. item.titleEdgeInsets = UIEdgeInsetsMake(0, self.frame.size.width-oneIndexWidth-5, 0, 5);
  26. [item setTitle:self.indexTitles[i] forState:UIControlStateNormal];
  27. [item setTitle:self.indexTitles[i] forState:UIControlStateHighlighted];
  28. [item setTitleColor:[UIColor blueColor] forState:UIControlStateNormal];
  29. [item setTitleColor:[UIColor blueColor] forState:UIControlStateHighlighted];
  30. item.backgroundColor = [UIColor clearColor];
  31. item.titleLabel.font = [UIFont systemFontOfSize:12];
  32. [item addTarget:self action:@selector(btnClickedDown:) forControlEvents:UIControlEventTouchDown]; //只要点击
  33. [item addTarget:self action:@selector(btnTouchUpInside:) forControlEvents:UIControlEventTouchUpInside]; //点击松手
  34. }

2.响应事件的

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

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

第三步 : 解决Bug

关键:tableView的Category

runtime方案的实现步骤如下:

  1. 拦截UITableView的系统setDelegate,用pp_setDelegate替换;
  2. 判断delegate是否响应系统的tableView:didSelectRowAtIndexPath: ,如果响应才能继续3;
  3. 根据系统的代理tableView:didSelectRowAtIndexPath: 方法和对应的指针给代理类[delegate class]利用runtime的class_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:,这样就实现了我们的需求。

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

代码如下:

  1. ///**
  2. // * 通过给定的方法名和实现,动态给类添加一个方法
  3. // *
  4. // * @param cls 要添加方法的类.
  5. // * @param name 指定了方法名的要添加到类的方法.
  6. // * @param imp 添加方法的函数实现(函数地址).
  7. // * @param types 函数的类型,(返回值+参数类型).
  8. // *
  9. // * @return 如果添加成功就返回YES,失败返回NO(如果该类中已经存在一个相同的方法名的方法实现).
  10. // *
  11. // * @note 注: class_addMethod将重写父类的实现,但是不会替换该类中已经有的实现,
  12. // 如果想改变该类中已有的实现,请使用method_setImplementation
  13. // */
  14. //OBJC_EXPORT BOOL class_addMethod(Class cls, SEL name, IMP imp,const char * types)
  15. -(void)pp_setDelegate:(id<UITableViewDelegate>)delegate
  16. {
  17. if(delegate){
  18. //系统原生的didSelected方法
  19. SEL systemDidSelectedSelector = @selector(tableView:didSelectRowAtIndexPath:);
  20. //我自己重写的didSelected方法(用于替换系统的)
  21. SEL ppDidSelectedSelector = @selector(pp_tableView:didSelectRowAtIndexPath:);
  22. //VIP:假的didSelected方法(和系统原生的didSelected方法指针地址相同,但不是一个方法,却响应同样事件)
  23. SEL fakeDidSelectedSelector = @selector(pp_fakeTableView:didSelectRowAtIndexPath:);
  24. if ([delegate respondsToSelector:systemDidSelectedSelector]) {
  25. //系统的代理didSelected方法和对应的指针
  26. Method systemDidSelectedMethod = class_getInstanceMethod([delegate class], @selector(tableView:didSelectRowAtIndexPath:));
  27. IMP systemDidSelectedMethodIMP = method_getImplementation(systemDidSelectedMethod);
  28. //VIP:此处给系统原生的didSelected方法上添加新的方法(只要方法名不一样,就可以成功,详见系统api中的@return说明)
  29. //注意此方法放置位置,不能放在下面的class_replaceMethod后面(因为已经被替换了,指针会指向tableView *的pp_tableView:didSelectRowAtIndexPath:)
  30. class_addMethod([delegate class], fakeDidSelectedSelector, systemDidSelectedMethodIMP, method_getTypeEncoding(systemDidSelectedMethod));
  31. //自己重写的didSelected,用来处理拦截后想做的事情(比如:发通知,block回调等)
  32. Method ppDidSelectedMethod = class_getInstanceMethod([self class], ppDidSelectedSelector);
  33. IMP ppDidSelectedMethodIMP = method_getImplementation(ppDidSelectedMethod);
  34. //用自己重写的替换系统原生的
  35. class_replaceMethod([delegate class], systemDidSelectedSelector, ppDidSelectedMethodIMP, method_getTypeEncoding(systemDidSelectedMethod));
  36. }
  37. }
  38. //拦截原生的delegate,别忘了调用(此处调用pp_setDelegate:实际上就是调用setDelegate:,说句不该说的话,此处看不懂,runtime你根本不会)
  39. [self pp_setDelegate:delegate];
  40. }
  41. -(void)pp_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
  42. {
  43. //方案(通知)都用的到的通知,很显然太费事,不方便
  44. /*
  45. NSNotification *not = [[NSNotification alloc]initWithName:PPTableViewDidSelectedNotificationKey object:nil userInfo:nil];
  46. [[NSNotificationCenter defaultCenter]postNotification:not];
  47. */
  48. //方案(runtime):给tableView添加点击block,。。。。嗯,有没有想到更多??简化所有的delegate与dataSource方法,此处不说太多
  49. if (tableView.pp_didSelectedBlock) {
  50. tableView.pp_didSelectedBlock(tableView, indexPath);
  51. }
  52. //VIP: 此时,系统原生的didSelected方法已经被拦截,并且做了你想做的事情,可是怎么让系统原生的didSelected还能响应点击?
  53. /*
  54. 方案一: 不用class_addMethod方法,而是在[delegate respondsToSelector:systemDidSelectedSelector]条件语句里
  55. 添加代理绑定:
  56. objc_setAssociatedObject(PPTableViewDidSelectedNotificationKey, @selector(pp_tableView:didSelectRowAtIndexPath:), delegate, OBJC_ASSOCIATION_RETAIN);
  57. 然后在此处,执行:
  58. id ppDelegate = objc_getAssociatedObject(PPTableViewDidSelectedNotificationKey, _cmd);
  59. [ppDelegate pp_tableView:tableView didSelectRowAtIndexPath:indexPath];
  60. 哦😯,傻了,这相当于直接调用系统的didSelected方法,/(ㄒoㄒ)/~~,错!错!错!
  61. */
  62. //方案二:利用NSInvocation底层发消息,如下:
  63. SEL fakeDidSelectedSelector = @selector(pp_fakeTableView:didSelectRowAtIndexPath:);
  64. NSMethodSignature *methodSignature = [[tableView.delegate class]instanceMethodSignatureForSelector:fakeDidSelectedSelector];
  65. if (methodSignature == nil) {
  66. //可以抛出异常也可以不操作。
  67. }
  68. NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
  69. invocation.target = tableView.delegate;
  70. invocation.selector = fakeDidSelectedSelector;
  71. [invocation retainArguments];
  72. [invocation invoke];
  73. }

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

歌词

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注