UISearchBar performance issue with Core Data

2020-06-29 03:19发布

问题:

When I use UISearchBar and write something as a search string this whole searching thing gets a little laggy. My guess is that I'm messing UI stuff and Core Data in the main thread but I might be wrong. What is more, I use one entity for all of this stuff so there are no relations etc. there are 3321 objects in this table and this app is consuming approximately 12 to 14 MB of RAM what you can see on the screenshot below:

I thought that it'll be more efficent as 3321 objects isn't so much. For all Core Data stuff I use MagicalReacord. I operate on one instance of NSFetchedResultController but switching NSPredicate between main table view alnd search table view.
But nothing will be more valuable that source code so here you go:

#import "GroupsViewController.h"
#import "CashURLs.h"
#import "Group.h"
#import <AFNetworking.h>

@interface GroupsViewController ()
{
    NSPredicate *resultPredicate;
    UIRefreshControl *refreshControl;
}

@property (strong, nonatomic) NSMutableArray *selectedGroups;
@property (strong, nonatomic) NSFetchedResultsController *groupsFRC;

@end

@implementation GroupsViewController

-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

    // Getting array of selected groups
    self.selectedGroups = [NSMutableArray arrayWithArray:[[NSUserDefaults standardUserDefaults] objectForKey:@"SelectedGroups"]];
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    resultPredicate = nil;

    // Initializing pull to refresh
    refreshControl = [[UIRefreshControl alloc] init];
    [refreshControl addTarget:self action:@selector(refreshData) forControlEvents:UIControlEventValueChanged];
    [self.tableView addSubview:refreshControl];

    // Check if there is at least one Group entity in persistat store
    if (![Group MR_hasAtLeastOneEntity]) {
        [self refreshData];
    } else {
        [self refreshFRC];
        [self.tableView reloadData];
    }
}

#pragma mark - Downloading

-(void)refreshData
{
    // Show refresh control
    [refreshControl beginRefreshing];

    // On refresh delete all previous groups (To avoid duplicates and ghost-groups)
    [Group MR_truncateAll];

    [[AFHTTPRequestOperationManager manager] GET:ALL_GROUPS parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
        // For each group from downloaded JSON...
        for (id group in responseObject) {
            // ... Create entity and filll it with data
            Group *groupEntity = [Group MR_createEntity];

            groupEntity.name = [group valueForKey:@"name"];
            groupEntity.cashID = [group valueForKey:@"id"];
            groupEntity.sectionLetter = [[[group valueForKey:@"name"] substringToIndex:1] uppercaseString];
            groupEntity.caseInsensitiveName = [[group valueForKey:@"name"] lowercaseString];
        }

        // Save Groups to persistent store
        [[NSManagedObjectContext MR_defaultContext] MR_saveToPersistentStoreAndWait];
        [self refreshFRC];
        [self.tableView reloadData];
        [refreshControl endRefreshing];
    } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        NSLog(@"Failed to load data: %@", [error localizedDescription]);
        // End refreshing
        [refreshControl endRefreshing];

        // Show alert with info about internet connection
        UIAlertView *internetAlert = [[UIAlertView alloc] initWithTitle:@"Ups!" message:@"Wygląda na to, że nie masz połączenia z internetem" delegate:self cancelButtonTitle:@"OK" otherButtonTitles:nil, nil];
        [internetAlert show];
    }];
}

#pragma mark - Table View

-(NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    // Count sections in FRC
    return [[self.groupsFRC sections] count];
}

-(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    // Count groups in each section of FRC
    return [[[self.groupsFRC sections] objectAtIndex:section] numberOfObjects];
}

-(UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // Get reusable cell
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"GroupCell"];

    // If there isn't any create new one
    if (cell == nil) {
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"GroupCell"];
    }

    Group *group = [self.groupsFRC objectAtIndexPath:indexPath];

    cell.textLabel.text = group.name;

    // Checking if group has been selected earlier
    if ([self.selectedGroups containsObject:@{@"name" : group.name, @"id" : group.cashID}]) {
        [cell setAccessoryType:UITableViewCellAccessoryCheckmark];
    } else {
        [cell setAccessoryType:UITableViewCellAccessoryNone];
    }

    return cell;
}

// Adding checkmark to selected cell
-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath:indexPath];
    Group *group = [self.groupsFRC objectAtIndexPath:indexPath];

    // Checking if selected cell has accessory view set to checkmark and add group to selected groups array
    if (selectedCell.accessoryType == UITableViewCellAccessoryNone)
    {
        selectedCell.accessoryType = UITableViewCellAccessoryCheckmark;
        [self.selectedGroups addObject:@{@"name" : group.name, @"id" : group.cashID}];
        NSLog(@"%@", self.selectedGroups);
    }
    else if (selectedCell.accessoryType == UITableViewCellAccessoryCheckmark)
    {
        selectedCell.accessoryType = UITableViewCellAccessoryNone;
        [self.selectedGroups removeObject:@{@"name" : group.name, @"id" : group.cashID}];
        NSLog(@"%@", self.selectedGroups);
    }

    // Hiding selection with animation for nice and clean effect
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
}

#pragma mark - Filtering/Searching

// Seting searching predicate if there are any characters in search bar
-(void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText
{
    if (searchText.length > 0) {
        resultPredicate = [NSPredicate predicateWithFormat:@"SELF.caseInsensitiveName CONTAINS[c] %@", searchText];
    } else {
        resultPredicate = nil;
    }

    [self refreshFRC];
}

-(void)searchBarCancelButtonClicked:(UISearchBar *)searchBar
{
    // If user cancels searching we set predicate to nil
    resultPredicate = nil;
    [self refreshFRC];
}

// Refreshing NSFetchedResultController
- (void)refreshFRC
{
    self.groupsFRC = [Group MR_fetchAllSortedBy:@"caseInsensitiveName"
                                      ascending:YES
                                  withPredicate:resultPredicate
                                        groupBy:@"sectionLetter"
                                       delegate:self];
}

I read in one thread that CONTAINS could be resource consuming but I really have no idea how to implement it other way. My other guess was to put this searching in another queue and do it asynchronously, separate it from the UI... It isn't so laggy that UI will have to wait for ages for reloaded table view. But is it the right way? I have to improve preformance of this UISearchBar because I don't want to have unhappy customers.
I hope you could proide me with some ideas or you have any improvements for my code

回答1:

First, CONTAINS is slow. While that information doesn't help your problem, it is good to know that you are fighting up hill to start with.

Second, you are hitting the disk on every letter pressed. That is wasteful and of course slow.

You are using Magical Record which appears to be building a new NSFetchedResultsController on every letter press. That is wasteful and of course slow.

What should you do?

On the first letter press do a simple NSFetchRequest and keep the batch size down and maybe even keep the fetch limit down. Retain the NSArray that comes out of that and use it to display the results. Yes this will make your UITableViewDataSource more complicated.

On the second and subsequent letters press you filter against the existing NSArray. You do not go back out to disk.

If a delete is detected you blow away the array and rebuild it from disk.

This will limit your disk hits to just the first letter and when deletes are detected which will radically increase your search times.

Update

Regarding Magical Record. I have very graybeard opinions on third party frameworks. I always recommend avoiding them. That avoidance has nothing to do with code quality it has to do with staying as close to the metal as possible. MR is a layer on top of Core Data that I don't see a value in. Of course I also don't like dot syntax so take my opinions with a grain of salt :)

Yes, you should store the first search results in an array and then display against that array. It will be faster.

As for CONTAINS; not sure if you can avoid it. It is slow but it works and you are doing string compares so there is not much you can do on that front. So fix everything else so that you are not paying more computational tax than you need to.