Home > Articles > Mobile Application Development & Programming

From the Rough Cut Migration Manager

Migration Manager

Instead of letting a persistent store coordinator perform store migrations, you may wish to use a migration manager. Using a migration manager gives you total control over the files created during a migration and thus the flexibility to handle each aspect of a migration in your own way. One example of a benefit of using a migration manager is that you can report on the progress of a migration, which is useful for keeping the user informed (and less cranky) about a slow launch. Although most migrations should be quite fast, some large databases requiring complex changes can take a while to migrate. To keep the user interface responsive, the migration must be performed on a background thread. The user interface has to be responsive in order to provide updates to the user. The challenge is to prevent the user from attempting to use the application during the migration. This is because the data won’t be ready yet, so you don’t want the user staring at a blank screen wondering what’s going on. This is where a migration progress View Controller comes into play.

Update Grocery Dude as follows to configure a migration View Controller:

  1. Select Main.storyboard.
  2. Drag a new View Controller onto the storyboard, placing it above the existing Navigation Controller.
  3. Drag a new Label and Progress View onto the new View Controller.
  4. Position the Progress View in the center of the View Controller and then position the Label above it.
  5. Widen the Label and Progress View to the width of the View Controller margins, as shown on the left in Figure 3.10.
  6. Configure the Label with Centered text that reads Migration Progress 0%, as shown on the left in Figure 3.10.
  7. Configure the Progress View progress to 0.
  8. Set the Storyboard ID of the View Controller to migration using Identity Inspector (Option+⌘+3) while the View Controller is selected.
  9. Click Editor > Resolve Auto Layout Issues > Reset to Suggested Constraints in View Controller. Figure 3.10 shows the expected result.

Figure 3.10 Migration View Controller

The new migration View Controller has UILabel and UIProgressView interface elements that will need updating during a migration. This means a way to refer to these interface elements in code is required. A new UIViewController subclass called MigrationVC will be created for this purpose.

Update Grocery Dude as follows to add a MigrationVC class in a new group:

  1. Right-click the existing Grocery Dude group and then select New Group.
  2. Set the new group name to Grocery Dude View Controllers.
  3. Select the Grocery Dude View Controllers group.
  4. Click File > New > File....
  5. Create a new iOS > Cocoa Touch > Objective-C class and then click Next.
  6. Set Subclass of to UIViewController and Class name to MigrationVC and then click Next.
  7. Ensure the Grocery Dude target is ticked and then create the class in the Grocery Dude project directory.
  8. Select Main.storyboard.
  9. Set the Custom Class of the new migration View Controller to MigrationVC using Identity Inspector (Option+⌘+3) while the View Controller is selected. This is in the same place as where the Storyboard ID was set.
  10. Show the Assistant Editor by clicking View > Assistant Editor > Show Assistant Editor (or pressing Option+⌘+Return).
  11. Ensure the Assistant Editor is automatically showing MigrationVC.h. The top-right of Figure 3.11 shows what this looks like. If you need to, just click Manual or Automatic while the migration View Controller is selected and select MigrationVC.h.
  12. Hold down Control while dragging a line from the migration progress label to the code in MigrationVC.h before @end. When you let go of the left mouse button, a pop-up will appear. In the pop-up, set the Name of the new UILabel property to label and ensure the Storage is set to Strong before clicking Connect. Figure 3.11 shows the intended configuration.
  13. Repeat the technique in step 12 to create a linked UIProgressView property from the progress view called progressView.

Figure 3.11 Creating storyboard-linked properties to MigrationVC.h

To report migration progress, a pointer to the migration View Controller is required in CoreDataHelper.h.

Update Grocery Dude as follows to add a new property:

  1. Show the Standard Editor by clicking View > Standard Editor > Show Standard Editor (or pressing ⌘+Return).
  2. Add #import "MigrationVC.h" to the top of CoreDataHelper.h.
  3. Add @property (nonatomic, retain) MigrationVC *migrationVC; to CoreDataHelper.h beneath the existing properties.

To handle migrations manually, you’ll need to work out whether a migration is necessary each time the application is launched. To make this determination, you’ll need to know the URL of the store you’re checking to see that it actually exists. Providing that it does, you then compare the store’s model metadata to the new model. The result of this model comparison is used to determine whether the new model is compatible with the existing store. If it’s not, migration is required. The isMigrationNecessaryForStore method shown in Listing 3.5 demonstrates how these checks translate into code.

Listing 3.5  CoreDataHelper.m: isMigrationNecessaryForStore

#pragma mark - MIGRATION MANAGER
- (BOOL)isMigrationNecessaryForStore:(NSURL*)storeUrl {
if (debug==1) {
    NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
if (![[NSFileManager defaultManager] fileExistsAtPath:[self storeURL].path]) {
    if (debug==1) {NSLog(@"SKIPPED MIGRATION: Source database missing.");}
    return NO;
}
NSError *error = nil;
NSDictionary *sourceMetadata = 
[NSPersistentStoreCoordinator metadataForPersistentStoreOfType:NSSQLiteStoreType
 URL:storeUrl error:&error];
NSManagedObjectModel *destinationModel = _coordinator.managedObjectModel;
if ([destinationModel isConfiguration:nil
 compatibleWithStoreMetadata:sourceMetadata]) {
if (debug==1) {
    NSLog(@"SKIPPED MIGRATION: Source is already compatible");}
    return NO;
}
return YES;
}

Update Grocery Dude as follows to implement a new MIGRATION MANAGER section:

  1. Add the code from Listing 3.5 to the bottom of CoreDataHelper.m before @end.

Provided migration is necessary, the next step is to perform migration. Migration is a three-step process, as shown by the comments in Listing 3.6.

Listing 3.6  CoreDataHelper.m: migrateStore

- (BOOL)migrateStore:(NSURL*)sourceStore {
if (debug==1) {
    NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
    BOOL success = NO;
    NSError *error = nil;

    // STEP 1 - Gather the Source, Destination and Mapping Model
    NSDictionary *sourceMetadata = [NSPersistentStoreCoordinator
    metadataForPersistentStoreOfType:NSSQLiteStoreType 
                                 URL:sourceStore 
                               error:&error];
    
    NSManagedObjectModel *sourceModel = 
    [NSManagedObjectModel mergedModelFromBundles:nil 
                                forStoreMetadata:sourceMetadata];
    
    NSManagedObjectModel *destinModel = _model;

    NSMappingModel *mappingModel = 
    [NSMappingModel mappingModelFromBundles:nil 
                             forSourceModel:sourceModel 
                           destinationModel:destinModel];

    // STEP 2 - Perform migration, assuming the mapping model isn't null
    if (mappingModel) {
        NSError *error = nil;
        NSMigrationManager *migrationManager = 
      [[NSMigrationManager alloc] initWithSourceModel:sourceModel
                                     destinationModel:destinModel];
        [migrationManager addObserver:self 
                           forKeyPath:@"migrationProgress" 
                              options:NSKeyValueObservingOptionNew 
                              context:NULL];

        NSURL *destinStore = 
        [[self applicationStoresDirectory]  
          URLByAppendingPathComponent:@"Temp.sqlite"];

        success = 
        [migrationManager migrateStoreFromURL:sourceStore
               type:NSSQLiteStoreType options:nil
                             withMappingModel:mappingModel
                             toDestinationURL:destinStore 
                              destinationType:NSSQLiteStoreType
                           destinationOptions:nil
                                        error:&error];
        if (success) {
            // STEP 3 - Replace the old store with the new migrated store
            if ([self replaceStore:sourceStore withStore:destinStore]) {
                if (debug==1) {
                NSLog(@"SUCCESSFULLY MIGRATED %@ to the Current Model",
                                                    sourceStore.path);}
                [migrationManager removeObserver:self
                                      forKeyPath:@"migrationProgress"];
            }
        }
        else {
            if (debug==1) {NSLog(@"FAILED MIGRATION: %@",error);}
        }
    }
    else {
        if (debug==1) {NSLog(@"FAILED MIGRATION: Mapping Model is null");}
    }
    return YES; // indicates migration has finished, regardless of outcome
}

STEP 1 involves gathering the things you need to perform a migration, which are as follows:

  • A source model, which you get from the metadata of a persistent store through its coordinator via metadataForPersistentStoreOfType
  • A destination model, which is just the existing _model instance variable
  • A mapping model, which is determined automatically by passing nil as the bundle along with the source and destination models

STEP 2 is the process of the actual migration. An instance of NSMigrationManager is created using the source and destination models. Before migrateStoreFromURL is called, a destination store is set. This destination store is just a temporary store that’s only used for migration purposes.

STEP 3 is only triggered when a migration has succeeded. The replaceStore method is used to clean up after a successful migration. When migration occurs, a new store is created at the destination; yet, this is no good to Core Data until the migrated store has the same location and filename as the old store. In order to use the newly migrated store, the old store is deleted and the new store is put in its place. In your own projects you may wish to copy the old store to a backup location first. The option to keep a store backup is up to you and would require slightly modified code in the replaceStore method. If you do decide to back up the old store, be aware that you’ll double your application’s storage requirements in the process.

The migration process is made visible to the user by an observeValueForKeyPath method that is called whenever the migration progress changes. This method is responsible for updating the migration progress View Controller whenever it sees a change to the migrationProgress property of the migration manager.

The code involved in the observeValueForKeyPath and replaceStore methods is shown in Listing 3.7.

Listing 3.7  CoreDataHelper.m: observeValueForKeyPath and replaceStore

- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary *)change 
                       context:(void *)context {

    if ([keyPath isEqualToString:@"migrationProgress"]) {

        dispatch_async(dispatch_get_main_queue(), ^{

            float progress = 
            [[change objectForKey:NSKeyValueChangeNewKey] floatValue];
            self.migrationVC.progressView.progress = progress;
            int percentage = progress * 100;
            NSString *string = 
            [NSString stringWithFormat:@"Migration Progress: %i%%",
                                                       percentage];
            NSLog(@"%@",string);
            self.migrationVC.label.text = string;
        });
    }
}
- (BOOL)replaceStore:(NSURL*)old withStore:(NSURL*)new {

    BOOL success = NO;
    NSError *Error = nil;
    if ([[NSFileManager defaultManager]
         removeItemAtURL:old error:&Error]) {

        Error = nil;
        if ([[NSFileManager defaultManager]
             moveItemAtURL:new toURL:old error:&Error]) {
            success = YES;
        }
        else {
            if (debug==1) {NSLog(@"FAILED to re-home new store %@", Error);}
        }
    }
    else {
        if (debug==1) {
            NSLog(@"FAILED to remove old store %@: Error:%@", old, Error);
        }
    }
    return success;
}

Update Grocery Dude as follows to continue implementing the MIGRATION MANAGER section:

  1. Add the code from Listing 3.7 and then Listing 3.6 to the MIGRATION MANAGER section at the bottom of CoreDataHelper.m before @end.

To start a migration in the background using a migration manager, the method shown in Listing 3.8 is needed.

Listing 3.8  CoreDataHelper.m: performBackgroundManagedMigrationForStore

- (void)performBackgroundManagedMigrationForStore:(NSURL*)storeURL {
if (debug==1) {
    NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}

    // Show migration progress view preventing the user from using the app
    UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
    self.migrationVC = 
    [sb instantiateViewControllerWithIdentifier:@"migration"];
    UIApplication *sa = [UIApplication sharedApplication];
    UINavigationController *nc =
    (UINavigationController*)sa.keyWindow.rootViewController;
    [nc presentViewController:self.migrationVC animated:NO completion:nil];

    // Perform migration in the background, so it doesn't freeze the UI.
    // This way progress can be shown to the user
    dispatch_async(
    dispatch_get_global_queue(
    DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
        BOOL done = [self migrateStore:storeURL];
        if(done) {
            // When migration finishes, add the newly migrated store
            dispatch_async(dispatch_get_main_queue(), ^{
                NSError *error = nil;
                _store =
                [_coordinator addPersistentStoreWithType:NSSQLiteStoreType
                                           configuration:nil
                                                     URL:[self storeURL]
                                                 options:nil 
                                                   error:&error];
                if (!_store) {
                    NSLog(@"Failed to add a migrated store. Error: %@",
                    error);abort();}
                else {
                    NSLog(@"Successfully added a migrated store: %@",
                    _store);}
                [self.migrationVC dismissViewControllerAnimated:NO
                                                     completion:nil];
                self.migrationVC = nil;
            });
        }
    });
}

The performBackgroundManagedMigrationForStore method uses a storyboard identifier to instantiate and present the migration view. Once the view is blocking user interaction, the migration can begin. The migrateStore method is called on a background thread. Once migration is complete, the coordinator then adds the store as usual, the migration view is dismissed, and normal use of the application can resume.

Update Grocery Dude as follows to continue implementing the MIGRATION MANAGER section:

  1. Add the code from Listing 3.8 to the MIGRATION MANAGER section at the bottom of CoreDataHelper.m before @end.

The best time to check whether migration is necessary is just before a store is added to a coordinator. To orchestrate this, the loadStore method of CoreDataHelper.m needs to be updated. If a migration is necessary, it will be triggered here. Listing 3.9 shows the code involved.

Listing 3.9  CoreDataHelper.m: loadStore

if (debug==1) {
    NSLog(@"Running %@ '%@'", self.class, NSStringFromSelector(_cmd));
}
    if (_store) {return;} // Don’t load store if it’s already loaded

    BOOL useMigrationManager = YES;
    if (useMigrationManager && 
       [self isMigrationNecessaryForStore:[self storeURL]]) {
        [self performBackgroundManagedMigrationForStore:[self storeURL]];
    } else {
        NSDictionary *options =
        @{
          NSMigratePersistentStoresAutomaticallyOption:@YES
          ,NSInferMappingModelAutomaticallyOption:@NO
          ,NSSQLitePragmasOption: @{@"journal_mode": @"DELETE"}
          };
        NSError *error = nil;
        _store = [_coordinator addPersistentStoreWithType:NSSQLiteStoreType
                                            configuration:nil
                                                      URL:[self storeURL]
                                                  options:options
                                                    error:&error];
        if (!_store) {
            NSLog(@"Failed to add store. Error: %@", error);abort();
       }
        else         {NSLog(@"Successfully added store: %@", _store);}
    }

Update Grocery Dude as follows to finalize the Migration Manager:

  1. Replace all existing code in the loadStore method of CoreDataHelper.m with the code from Listing 3.9.
  2. Add a model version called Model 4 based on Model 3.
  3. Select Model 4.xcdatamodel.
  4. Delete the Amount entity.
  5. Add a new entity called Unit with a String attribute called name.
  6. Create an NSManagedObject subclass of the Unit entity. When it comes time to save the class file, don’t forget to tick the Grocery Dude target.
  7. Set Model 4 as the current model.
  8. Create a new mapping model with Model 3 as the source and Model 4 as the target. When it comes time to save the mapping model file, don’t forget to tick the Grocery Dude target and save the mapping model as Model3toModel4.
  9. Select Model3toModel4.xcmappingmodel.
  10. Select the Unit entity mapping.
  11. Set the Source of the Unit entity to Amount and the Value Expression of the name destination attribute to $source.xyz. You should see the Unit entity mapping automatically renamed to AmountToUnit, as shown in Figure 3.12.

Figure 3.12 Mapping model for AmountToUnit

You’re almost ready to perform a migration; however, the fetch request in the demo method still refers to the old Amount entity.

Update Grocery Dude as follows to refer to the Unit entity instead of the Amount entity:

  1. Replace #import "Amount.h" with #import "Unit.h" at the top of AppDelegate.m.
  2. Replace the code in the demo method of AppDelegate.m with the code shown in Listing 3.10. This code just fetches 50 Unit objects from the persistent store.

Listing 3.10  AppDelegate.m: demo (Fetching Test Unit Data)

    NSFetchRequest *request =
    [NSFetchRequest fetchRequestWithEntityName:@"Unit"];
    [request setFetchLimit:50];
    NSError *error = nil;
    NSArray *fetchedObjects =
    [_coreDataHelper.context executeFetchRequest:request error:&error];

    if (error) {NSLog(@"%@", error);}
    else {
        for (Unit *unit in fetchedObjects) {
            NSLog(@"Fetched Object = %@", unit.name);
        }
    }

The migration manager is finally ready! Run the application and pay close attention! You should see the migration manager flash before your eyes, alerting you to the progress of the migration. The progress will also be shown in the console log.

Figure 3.13 Visible migration progress

Examine the contents of the ZUNIT table in the Grocery-Dude.sqlite file using the techniques discussed in Chapter 2. The expected result is shown in Figure 3.14. If you notice a -wal file in the Stores directory and you’re sure that the default journaling mode is disabled, you might need to click Product > Clean and run the application again to examine the contents of the sqlite file.

Figure 3.14 Successful use of the Migration Manager

If you’ve reproduced the results shown in Figure 3.14, give yourself a pat on the back because you’ve successfully implemented three types of model migration! The rest of the book will use lightweight migrations, so it needs to be re-enabled.

Update Grocery Dude as follows to re-enable lightweight migration:

  1. Set the NSInferMappingModelAutomaticallyOption option in the loadStore method of CoreDataHelper.m to @YES.
  2. Set useMigrationManager to NO in the loadStore method of CoreDataHelper.m.
  3. Remove all code from the demo method of AppDelegate.m.

The old mapping models and NSManagedObject subclasses of entities that don’t exist anymore are no longer needed. Although you could remove them, leave them in the project for reference sake.

InformIT Promotional Mailings & Special Offers

I would like to receive exclusive offers and hear about products from InformIT and its family of brands. I can unsubscribe at any time.

Overview


Pearson Education, Inc., 221 River Street, Hoboken, New Jersey 07030, (Pearson) presents this site to provide information about products and services that can be purchased through this site.

This privacy notice provides an overview of our commitment to privacy and describes how we collect, protect, use and share personal information collected through this site. Please note that other Pearson websites and online products and services have their own separate privacy policies.

Collection and Use of Information


To conduct business and deliver products and services, Pearson collects and uses personal information in several ways in connection with this site, including:

Questions and Inquiries

For inquiries and questions, we collect the inquiry or question, together with name, contact details (email address, phone number and mailing address) and any other additional information voluntarily submitted to us through a Contact Us form or an email. We use this information to address the inquiry and respond to the question.

Online Store

For orders and purchases placed through our online store on this site, we collect order details, name, institution name and address (if applicable), email address, phone number, shipping and billing addresses, credit/debit card information, shipping options and any instructions. We use this information to complete transactions, fulfill orders, communicate with individuals placing orders or visiting the online store, and for related purposes.

Surveys

Pearson may offer opportunities to provide feedback or participate in surveys, including surveys evaluating Pearson products, services or sites. Participation is voluntary. Pearson collects information requested in the survey questions and uses the information to evaluate, support, maintain and improve products, services or sites, develop new products and services, conduct educational research and for other purposes specified in the survey.

Contests and Drawings

Occasionally, we may sponsor a contest or drawing. Participation is optional. Pearson collects name, contact information and other information specified on the entry form for the contest or drawing to conduct the contest or drawing. Pearson may collect additional personal information from the winners of a contest or drawing in order to award the prize and for tax reporting purposes, as required by law.

Newsletters

If you have elected to receive email newsletters or promotional mailings and special offers but want to unsubscribe, simply email information@informit.com.

Service Announcements

On rare occasions it is necessary to send out a strictly service related announcement. For instance, if our service is temporarily suspended for maintenance we might send users an email. Generally, users may not opt-out of these communications, though they can deactivate their account information. However, these communications are not promotional in nature.

Customer Service

We communicate with users on a regular basis to provide requested services and in regard to issues relating to their account we reply via email or phone in accordance with the users' wishes when a user submits their information through our Contact Us form.

Other Collection and Use of Information


Application and System Logs

Pearson automatically collects log data to help ensure the delivery, availability and security of this site. Log data may include technical information about how a user or visitor connected to this site, such as browser type, type of computer/device, operating system, internet service provider and IP address. We use this information for support purposes and to monitor the health of the site, identify problems, improve service, detect unauthorized access and fraudulent activity, prevent and respond to security incidents and appropriately scale computing resources.

Web Analytics

Pearson may use third party web trend analytical services, including Google Analytics, to collect visitor information, such as IP addresses, browser types, referring pages, pages visited and time spent on a particular site. While these analytical services collect and report information on an anonymous basis, they may use cookies to gather web trend information. The information gathered may enable Pearson (but not the third party web trend services) to link information with application and system log data. Pearson uses this information for system administration and to identify problems, improve service, detect unauthorized access and fraudulent activity, prevent and respond to security incidents, appropriately scale computing resources and otherwise support and deliver this site and its services.

Cookies and Related Technologies

This site uses cookies and similar technologies to personalize content, measure traffic patterns, control security, track use and access of information on this site, and provide interest-based messages and advertising. Users can manage and block the use of cookies through their browser. Disabling or blocking certain cookies may limit the functionality of this site.

Do Not Track

This site currently does not respond to Do Not Track signals.

Security


Pearson uses appropriate physical, administrative and technical security measures to protect personal information from unauthorized access, use and disclosure.

Children


This site is not directed to children under the age of 13.

Marketing


Pearson may send or direct marketing communications to users, provided that

  • Pearson will not use personal information collected or processed as a K-12 school service provider for the purpose of directed or targeted advertising.
  • Such marketing is consistent with applicable law and Pearson's legal obligations.
  • Pearson will not knowingly direct or send marketing communications to an individual who has expressed a preference not to receive marketing.
  • Where required by applicable law, express or implied consent to marketing exists and has not been withdrawn.

Pearson may provide personal information to a third party service provider on a restricted basis to provide marketing solely on behalf of Pearson or an affiliate or customer for whom Pearson is a service provider. Marketing preferences may be changed at any time.

Correcting/Updating Personal Information


If a user's personally identifiable information changes (such as your postal address or email address), we provide a way to correct or update that user's personal data provided to us. This can be done on the Account page. If a user no longer desires our service and desires to delete his or her account, please contact us at customer-service@informit.com and we will process the deletion of a user's account.

Choice/Opt-out


Users can always make an informed choice as to whether they should proceed with certain services offered by InformIT. If you choose to remove yourself from our mailing list(s) simply visit the following page and uncheck any communication you no longer want to receive: www.informit.com/u.aspx.

Sale of Personal Information


Pearson does not rent or sell personal information in exchange for any payment of money.

While Pearson does not sell personal information, as defined in Nevada law, Nevada residents may email a request for no sale of their personal information to NevadaDesignatedRequest@pearson.com.

Supplemental Privacy Statement for California Residents


California residents should read our Supplemental privacy statement for California residents in conjunction with this Privacy Notice. The Supplemental privacy statement for California residents explains Pearson's commitment to comply with California law and applies to personal information of California residents collected in connection with this site and the Services.

Sharing and Disclosure


Pearson may disclose personal information, as follows:

  • As required by law.
  • With the consent of the individual (or their parent, if the individual is a minor)
  • In response to a subpoena, court order or legal process, to the extent permitted or required by law
  • To protect the security and safety of individuals, data, assets and systems, consistent with applicable law
  • In connection the sale, joint venture or other transfer of some or all of its company or assets, subject to the provisions of this Privacy Notice
  • To investigate or address actual or suspected fraud or other illegal activities
  • To exercise its legal rights, including enforcement of the Terms of Use for this site or another contract
  • To affiliated Pearson companies and other companies and organizations who perform work for Pearson and are obligated to protect the privacy of personal information consistent with this Privacy Notice
  • To a school, organization, company or government agency, where Pearson collects or processes the personal information in a school setting or on behalf of such organization, company or government agency.

Links


This web site contains links to other sites. Please be aware that we are not responsible for the privacy practices of such other sites. We encourage our users to be aware when they leave our site and to read the privacy statements of each and every web site that collects Personal Information. This privacy statement applies solely to information collected by this web site.

Requests and Contact


Please contact us about this Privacy Notice or if you have any requests or questions relating to the privacy of your personal information.

Changes to this Privacy Notice


We may revise this Privacy Notice through an updated posting. We will identify the effective date of the revision in the posting. Often, updates are made to provide greater clarity or to comply with changes in regulatory requirements. If the updates involve material changes to the collection, protection, use or disclosure of Personal Information, Pearson will provide notice of the change through a conspicuous notice on this site or other appropriate way. Continued use of the site after the effective date of a posted revision evidences acceptance. Please contact us if you have questions or concerns about the Privacy Notice or any objection to any revisions.

Last Update: November 17, 2020