16

我有一个适用于 iPad 的现成企业(非 AppStore)遗留 iOS 应用程序,我需要对其进行重构(它是由另一位开发人员编写的,我目前工作的前任)。

此应用程序通过 JSON 从具有 MSSQL 数据库的服务器获取其数据。数据库模式有大约 30 个表,最大容量是:Client、City、Agency,每个表都有大约 10.000 条记录,预计未来会进一步增长。收到 JSON 后(每个表有一个 JSON 请求和响应对) - 它被映射到 CoreData - 该过程还包括将相应的 CoreData 实体(客户、城市、代理机构等)彼此粘合在一起,即在 CoreData 层上设置这些实体之间的关系。

该项目的CoreData fetch-part(或read-part)本身已经过大量优化 - 我猜它使用CoreData几乎所有可能的性能和内存调整,这就是为什么应用程序的UI层非常快速和响应,所以我认为其工作完全令人满意和充分。


问题是CoreData层的准备过程,即服务器到客户端的同步过程:耗时太长。考虑 30 个网络请求产生 30 个 JSON 包(“包”我的意思是“一个表 - 一个 JSON”),然后映射到 30 个 CoreData 实体,然后将它们粘合在一起(在它们之间设置适当的 CoreData 关系)。当我第一次看到这一切是如何在这个项目中完成的(太慢了)时,我脑海中浮现的第一个想法是:

“第一次执行完整的同步(应用程序的第一次启动时间) -在一个存档文件(类似于数据库转储)中执行整个数据库数据的获取,然后以某种方式将其作为一个整体导入核心数据土地”。

但后来我意识到,即使这种单一文件转储的传输是可能的,CoreData 仍然需要我对相应的 CoreData 实体进行粘合以设置它们之间的适当关系,因此很难想象我可以如果我依赖这个方案,性能上会受益。

另外,我的同事建议我将 SQLite 视为 Core Data 的完整替代品,但不幸的是我没有使用它的经验,这就是为什么我完全无法预见如此严肃的设计决策的所有后果(即使有同步过程很慢,我的应用程序确实可以工作,尤其是它的 UI 性能现在非常好)。关于 SQLite,我唯一能想象到的是,与 Core Data 相比,它不会促使我在客户端粘合一些额外的关系,因为 SQLite 有其良好的旧外键系统,不是吗?


以下是问题(受访者,请不要在回答时混淆这些观点——我对所有这些观点都感到困惑):

  1. 有没有人有像我上面描述的那样采取“首次大量导入整个数据库”方法的经验?如果他们是否利用 JSON<->CoreData 对,我将非常感谢了解任何解决方案。

  2. Core Data 是否有一些全局导入机制,可以允许大量创建相应的 30 个表模式(可能使用上述“30 包 JSON”以外的某些特定源),而无需为 30 个实体设置对应关系?

  3. 如果2)不可能,是否有可能加快同步过程?这里我指的是我的应用程序使用的当前 JSON<->CoreData 方案的改进。

  4. 迁移到 SQLite:我应该考虑这种迁移吗?我会从中得到什么好处?复制->传输->客户端准备的整个过程会是什么样子呢?

  5. CoreData 和 SQLite 的其他替代品——它们可能是什么或看起来像什么?

  6. 对于我所描述的情况,您可能还有其他想法或愿景吗?


更新 1

尽管 Mundi 写的答案很好(一个大的 JSON,对于使用 SQLite “否”),如果对我所描述的问题有任何其他见解,我仍然很感兴趣。


更新 2

我确实尝试使用我的俄语英语以最好的方式来描述我的情况,希望我的问题对所有阅读它的人来说都非常清楚。通过第二次更新,我将尝试为其提供更多指南,以使我的问题更加清晰。

请考虑两个二分法:

  1. 我可以/应该使用什么作为 iOS 客户端上的数据层 - CoreData vs SQLite?
  2. 我可以/应该使用什么作为传输层 - JSON(如答案中所建议的一次性单个 JSON,甚至可能压缩)或一些 DB-itself-dumps(如果它甚至可能的话,当然 - 请注意我是在我的问题中也问这个)。

我认为由这两个二分法的交集形成的“扇区”很明显,从第一个中选择 CoreData,从第二个中选择 JSON 是 iOS 开发世界中最广泛使用的默认值,我的应用程序也使用它从这个问题。

话虽如此,我声称我会很高兴看到有关 CoreData-JSON 对的任何答案以及考虑使用任何其他“部门”的答案(选择 SQLite 及其某种转储方法怎么样,为什么不呢?)

另外,需要注意的是,我不想仅仅放弃当前选项以获取其他一些替代方案,我只想让解决方案在其使用的同步和 UI 阶段都快速运行。因此,欢迎提供有关改进当前方案的答案以及建议其他方案的答案!

现在,请查看以下更新 #3,它提供了我当前 CoreData-JSON 情况的更多详细信息:


更新 3

正如我所说,目前我的应用程序收到 30 包 JSON - 整张桌子一包。让我们以大容量表为例:Client、Agency、City。

它是核心数据,所以如果一个client记录有非空agency_id字段,我需要创建一个新的核心数据实体类Agency (NSManagedObject subclass)并用这个记录的JSON数据填充它,这就是为什么我需要已经有这个类代理的相应核心数据实体Agency (NSManagedObject's subclass),最后我需要做一些事情client.agency = agency;,然后调用[currentManagedObjectContext save:&error]. 以这种方式完成后,稍后我可以要求获取此客户端并要求其.agency属性找到相应的实体。我希望当我这样做时我是完全清醒的。

现在想象一下这种模式应用于以下情况:

我刚刚收到以下 3 个单独的 JSON 包:10000 个客户和 4000 个城市和 6000 个代理(客户有一个城市,城市有很多客户;客户有代理,代理有很多客户,代理有一个城市,城市有很多代理)。

现在我想在核心数据级别设置以下关系:我希望我的客户实体client连接到相应的城市和相应的机构。

当前在项目中的实现做了非常丑陋的事情:

  1. 由于依赖顺序如下: City -> Agency -> Client 即首先需要烘焙 City,应用程序开始为 City 创建实体并将它们持久化到 Core Data。

  2. 然后它处理机构的 JSON:它遍历每个 JSON 记录 - 对于每个机构,它创建一个新实体agency,并通过city_id它的 获取相应的实体city并使用agency.city = city. 在完成整个机构 JSON 数组的迭代后,保存当前的托管对象上下文(实际上 -[managedObjectContext save:] 会执行多次,每次处理 500 条记录后)。在这一步,很明显,为 6000 个代理机构中的每一个的每个客户获取 4000 个城市中的一个对整个同步过程有巨大的性能影响。

  3. 然后,最后处理客户端的 JSON:和前 2 阶段一样,遍历整个 10000 元素的 JSON 数组,并逐个执行相应机构和 ZOMG 城市的 fetch,这会影响相同的整体性能就像之前的第 2 阶段一样。

这一切都非常糟糕。

我可以在这里看到的唯一性能优化是,第一阶段可以留下一个带有城市 ID 的大字典(我的意思是 NSNumber 的真实 ID)和错误的城市实体作为值),因此可以防止以下丑陋的查找过程第 2 阶段,然后使用类似的缓存技巧在第 3 阶段做同样的事情,但问题是在刚刚描述的所有 30 个表之间有更多的关系 [Client-City, Client-Agency, Agency-City] 所以涉及缓存所有实体的最终过程很可能会影响 iPad 设备为我的应用程序保留的资源。


更新 4

给未来受访者的信息:我已尽力使这个答案详细且格式正确,我真的希望您能用冗长的答案来回答。如果您的回答能够真正解决此处讨论的问题的复杂性,并补充我为使我的问题尽可能清晰和笼统而做出的努力,那就太好了。谢谢。

更新 5

相关主题:客户端 (iOS) 上的 Core Data 缓存来自服务器的数据 Strategy尝试使用 RestKit 发出 POST 请求并将响应映射到 Core Data

更新 6

即使不再可能打开新的赏金并且有接受的答案,我仍然很高兴看到任何其他答案,其中包含有关本主题解决的问题的其他信息。提前致谢。

4

7 回答 7

10

我有一个非常相似的项目的经验。核心数据插入需要一些时间,所以我们要求用户这将需要一段时间,但只是第一次。最好的性能调整当然是在保存之间获得正确的批量大小,但我相信你知道这一点。

一个性能建议:我尝试了一些事情,发现创建许多下载线程可能会影响性能,我想是因为对于每个请求,服务器等都有一些延迟。

相反,我发现一次下载所有 JSON速度要快得多。我不知道你有多少数据,但我用 > 100.000 条记录和 40MB+ JSON 字符串进行了测试,这真的很快,所以瓶颈只是核心数据插入。有了@autorelease游泳池,这在第一代 iPad 上的表现甚至可以接受。

远离 SQLite API - 你将花费超过一个人年的时间(提供高生产力)来复制你使用 Core Data 开箱即用的性能优化。

于 2013-07-22T20:41:36.030 回答
6

首先,您正在做很多工作,无论您如何切片都需要一些时间,但是有一些方法可以改进。

我建议您分批进行提取,批次大小与您的批次大小相匹配,以处理新对象。例如,在创建新Agency记录时,请执行以下操作:

  1. 确保当前Agency批次按city_id. (我稍后会解释原因)。

  2. 获取批次City中每个的 ID 。Agency根据您的 JSON 的结构方式,这可能是这样的单行代码(因为valueForKey适用于数组):

    NSArray *cityIDs = [myAgencyBatch valueForKey:@"city_id"];
    
  3. City使用您在上一步中找到的 ID 在一次提取中获取当前传递的所有实例。对结果进行排序city_id。就像是:

    NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"City"];
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"city_id in %@", cityIDs];
    [request setPredicate:predicate];
    [request setSortDescriptors:@[ [NSSortDescriptor sortDescriptorWithKey:@"city_id" ascending:YES] ]];
    NSArray *cities = [context executeFetchRequest:request error:nil];
    

现在,您有一个数组Agency和另一个City,都按 排序city_id。匹配它们以建立关系(检查city_id以防万一事情不匹配)。保存更改,然后继续下一批。

这将大大减少您需要执行的获取次数,这应该会加快速度。有关此技术的更多信息,请参阅Apple 文档中的“高效实现查找或创建”。

另一件可能有帮助的事情是在开始获取之前用您需要的对象“预热”Core Data 的内部缓存。这将在以后节省时间,因为获取属性值不需要访问数据存储。为此,您可以执行以下操作:

NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"City"];
// no predicate, get everything
[request setResultType:NSManagedObjectIDResultType];
NSArray *notUsed = [context executeFetchRequest:request error:nil];

..然后忘记结果。这从表面上看是无用的,但会改变内部核心数据状态,以便以后更快地访问City实例。

现在至于你的其他问题,

  • 直接使用 SQLite 而不是 Core Data 对于您的情况可能不是一个糟糕的选择。好处是您无需设置关系,因为您可以使用像city_id外键这样的字段。所以,快速导入。当然,缺点是您必须自己完成将模型对象转换为 SQL 记录/从 SQL 记录转换的工作,并且可能会重写很多假设 Core Data 的现有代码(例如,每次您遵循关系时,您现在需要通过该外键查找记录)。此更改可能会解决您的导入性能问题,但副作用可能很大。

  • 如果您以文本形式传输数据,JSON 通常是一种非常好的格式。如果您可以在服务器上准备一个 Core Data 存储,并且会按原样使用该文件而不是尝试将其合并到现有数据存储中,那么这几乎肯定会加快速度。您的导入过程将在服务器上运行一次,然后再也不会运行。但这些都是很大的“如果”,尤其是第二个。如果您到达需要将新服务器数据存储与现有数据合并的位置,您将立即回到现在的位置。

于 2013-07-30T21:26:02.817 回答
5

你对服务器有控制权吗?我问,因为这听起来像您从以下段落中所做的那样:

“第一次执行完整的同步(应用程序的第一次启动时间) - 在一个存档文件(类似于数据库转储)中执行整个数据库数据的获取,然后以某种方式将其作为一个整体导入 CoreData 土地”。

如果可以发送转储,为什么不发送核心数据文件本身呢?Core Data(默认情况下)由 SQLite 数据库支持——为什么不在服务器上生成该数据库,将其压缩并通过网络发送呢?

这意味着您可以消除所有 JSON 解析、网络请求等,并将其替换为简单的文件下载和存档提取。我们在一个项目上这样做了,它极大地提高了性能。

于 2013-08-02T08:49:31.040 回答
4
  1. 对于表中的每一行,都必须有一个时间戳列。如果没有,您应该添加它。
  2. 第一次和每次获取数据库转储时,都会存储上次更新日期和时间。
  3. 下次您指示数据库仅返回自上次下载操作以来更改或更新的那些记录。还应该有一个“已删除”标志供您删除消失的记录。
  4. 然后,您只需要更新某些匹配记录即可在各个方面节省时间。

为了加快首次同步,您还可以在应用程序中提供种子数据库,以便无需任何网络操作即可立即导入。

  1. 手动下载 JSON 文件。
  2. 将它们放入您的项目中。
  3. 在项目配置或头文件的某处记下下载日期和时间。
  4. 在第一次运行时,找到并加载所述文件,然后像更新它们一样继续。
  5. 如有疑问,请参阅手册。

例子:

NSString *filePath = [[NSBundle mainBundle] pathForResource:@"cities" 
                                            ofType:@"json"];
NSData *citiesData = [NSData dataWithContentsOfFile:filePath];
// I assume that you're loading an array
NSArray *citiesSeed = [NSJSONSerialization JSONObjectWithData:citiesData 
                       options:NSJSONReadingMutableContainers error:nil];
于 2013-08-02T08:26:59.527 回答
4

这里有我的建议:

  • 使用魔法记录。它是一个 CoreData 包装器,可以为您节省大量样板代码,而且它具有非常有趣的功能。
  • 正如其他人建议的那样,在一个请求中下载所有 JSON。如果您可以将第一个 JSON 文档嵌入到应用程序中,您可以节省下载时间并在您第一次打开应用程序时立即开始填充数据库。此外,使用magicrecord 很容易在单独的线程中执行此保存操作,然后自动同步所有上下文。这可以提高您的应用程序的响应能力。
  • 一旦解决了第一个导入问题,您似乎应该重构那个丑陋的方法。同样,我建议使用 magicrecord 轻松创建这些实体。
于 2013-08-05T09:25:59.460 回答
3

我们最近将一个相当大的项目从 Core Data 转移到 SQLite,主要原因之一是批量插入性能。我们在过渡过程中丢失了很多功能,如果可以避免的话,我不建议您进行切换。在转换到 SQLite 之后,我们实际上在 Core Data 透明地为我们处理的批量插入以外的领域遇到了性能问题,即使我们修复了这些新问题,也需要一些时间才能恢复运行。虽然我们花了一些时间和精力从 Core Data 过渡到 SQLite,但我不能说有任何遗憾。

弄清楚这一点后,我建议您在着手修复批量插入性能之前先进行一些基线测量。

  1. 测量在当前状态下插入这些记录需要多长时间。
  2. 完全跳过设置这些对象之间的关系,然后测量插入性能。
  3. 创建一个简单的 SQLite 数据库,并用它来衡量插入性能。这应该可以很好地估计执行实际 SQL 插入所需的时间,并且还可以让您对 Core Data 开销有一个很好的了解。

您可以立即尝试一些方法来加快插入速度:

  1. 确保在执行批量插入时没有活动的提取结果控制器。通过活动,我的意思是获取具有非零委托的结果控制器。根据我的经验,Core Data 的更改跟踪是尝试进行批量插入时最昂贵的操作。
  2. 在单个上下文中执行所有更改,并停止合并来自不同上下文的更改,直到完成此批量插入。

要更深入地了解幕后实际发生的事情,请启用Core Data SQL 调试并查看正在执行的 SQL 查询。理想情况下,您会希望看到很多 INSERT 和一些 UPDATE。但是,如果您遇到过多的 SELECT 和/或 UPDATE,则表明您正在阅读或更新对象过多。

使用 Core-Data 分析器工具可以更好地了解 Core Data 正在发生的事情。

于 2013-08-11T09:41:18.173 回答
2

我决定编写自己的答案,总结我发现对我的情况有用的技术和建议。感谢所有发布答案的人。


一、交通

  1. “一个 JSON”。这是我想尝试的想法。谢谢@mundi

  2. 在将 JSON 发送到客户端之前对其进行归档的想法,无论是一个 JSON 包还是 30 个单独的“一个表 - 一个包”。


二、建立核心数据关系

我将描述一个使用虚构的大型导入操作导入 JSON->CoreData 导入的过程,就好像它是在一种方法中执行的一样(我不确定它是否会这样 - 也许我将它分成逻辑块)。

让我们想象一下,在我想象的应用程序中有 15 个大容量表,其中“大容量”表示“不能一次保存在内存中,应该使用批量导入”和 15 个非大容量表,每个表都有 <500 条记录,例如:

宽敞:

  • 城市 (15k+)
  • 客户 (30k+)
  • 用户 (15k+)
  • 事件(5k+)
  • 行动 (2k+) ...

小的:

  • client_types (20-)
  • 访问类型 (10-)
  • 位置 (10-) ...

让我们想象一下,我已经下载了 JSON 包并将其解析为复合 NSArray/NSDictionary 变量:我有 cityJSON、clientsJSON、usersJSON、...

1. 先处理小桌子

我的伪方法首先导入小表。让我们以 client_types 表为例:我遍历clientTypesJSON并创建ClientType对象(NSManagedObject 的子类)。不仅如此,我在字典中收集结果对象,这些对象作为其值,这些对象的“ids”(外键)作为键。

这是伪代码:

NSMutableDictionary *clientTypesIdsAndClientTypes = [NSMutableDictionary dictionary];
for (NSDictionary *clientTypeJSON in clientsJSON) {
    ClientType *clientType = [NSEntityDescription insertNewObjectForEntityForName:@"ClientType" inManagedObjectContext:managedObjectContext];

    // fill the properties of clientType from clientTypeJSON

    // Write prepared clientType to a cache
    [clientTypesIdsAndClientTypes setValue:clientType forKey:clientType.id];
}

// Persist all clientTypes to a store.
NSArray *clientTypes = [clientTypesIdsAndClientTypes allValues];
[managedObjectContext obtainPermanentIDsForObjects:clientTypes error:...];

// Un-fault (unload from RAM) all the records in the cache - because we don't need them in memory anymore.
for (ClientType *clientType in clientTypes) {
    [managedObjectContext refreshObject:clientType mergeChanges:NO];
}

结果是我们有一堆小表的字典,每个都有相应的对象集和它们的 id。我们稍后将使用它们而无需重新获取,因为它们很小并且它们的值(NSManagedObjects)现在是错误的。

2. 使用第1步得到的小表中对象的缓存字典建立关系

让我们考虑一个复杂的表clients:我们有clientsJSON并且我们需要为每个客户记录建立一个clientType关系,这很容易,因为我们确实有一个缓存clientTypes和它们的 id:

for (NSDictionary *clientJSON in clientsJSON) {
    Client *client = [NSEntityDescription insertNewObjectForEntityForName:@"Client" inManagedObjectContext:managedObjectContext];

    // Setting up SQLite field 
    client.client_type_id = clientJSON[@"client_type_id"];

    // Setting up Core Data relationship beetween client and clientType
    client.clientType = clientTypesIdsAndClientTypes[client.client_type_id];
}

// Save and persist

3.处理大表——批量

让我们考虑一个clientsJSON拥有 30k+ 客户的大型企业。我们不会遍历整个clientsJSON,而是将其拆分为适当大小的块(500 条记录),因此[managedObjectContext save:...]每 500 条记录调用一次。此外,将每个 500 条记录批次的操作包装到一个中也很重要@autoreleasepool block- 请参阅核心数据性能指南中的减少内存开销

小心 - 步骤 4 描述了应用于一批 500 条记录而不是整个记录的操作clientsJSON

4.处理大表——与大表建立关系

考虑以下方法,我们稍后将使用:

@implementation NSManagedObject (Extensions)
+ (NSDictionary *)dictionaryOfExistingObjectsByIds:(NSArray *)objectIds inManagedObjectContext:(NSManagedObjectContext *)managedObjectContext {
    NSDictionary *dictionaryOfObjects;

    NSArray *sortedObjectIds = [objectIds sortedArrayUsingSelector:@selector(compare:)];

    NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass(self)];

    fetchRequest.predicate = [NSPredicate predicateWithFormat:@"(id IN %@)", sortedObjectIds];
    fetchRequest.sortDescriptors = @[[[NSSortDescriptor alloc] initWithKey: @"id" ascending:YES]];

    fetchRequest.includesPropertyValues = NO;
    fetchRequest.returnsObjectsAsFaults = YES;

    NSError *error;
    NSArray *fetchResult = [managedObjectContext executeFetchRequest:fetchRequest error:&error];

    dictionaryOfObjects = [NSMutableDictionary dictionaryWithObjects:fetchResult forKeys:sortedObjectIds];

    return dictionaryOfObjects;
}
@end

让我们考虑包含我们需要保存clientsJSON的一批 (500) 记录的包。Client此外,我们需要在这些客户及其代理机构之间建立关系(Agency外键为agency_id)。

NSMutableArray *agenciesIds = [NSMutableArray array];
NSMutableArray *clients = [NSMutableArray array];

for (NSDictionary *clientJSON in clientsJSON) {
    Client *client = [NSEntityDescription insertNewObjectForEntityForName:@"Client" inManagedObjectContext:managedObjectContext];

    // fill client fields...

    // Also collect agencies ids
    if ([agenciesIds containsObject:client.agency_id] == NO) {
        [agenciesIds addObject:client.agency_id];
    }        

    [clients addObject:client];
}

NSDictionary *agenciesIdsAndAgenciesObjects = [Agency dictionaryOfExistingObjectsByIds:agenciesIds];

// Setting up Core Data relationship beetween Client and Agency
for (Client *client in clients) {
    client.agency = agenciesIdsAndAgenciesObjects[client.agency_id];
}

// Persist all Clients to a store.
[managedObjectContext obtainPermanentIDsForObjects:clients error:...];

// Un-fault all the records in the cache - because we don't need them in memory anymore.
for (Client *client in clients) {
    [managedObjectContext refreshObject:client mergeChanges:NO];
}

我在这里使用的大部分内容都在这些 Apple 指南中进行了描述:核心数据性能高效导入数据。因此,步骤 1-4 的摘要如下:

  1. 当对象被持久化时将它们变成故障,因此随着导入操作的深入,它们的属性值变得不必要。

  2. 用对象作为值和它们ids作为键来构造字典,因此这些字典可以在构建这些对象和其他对象之间的关系时用作查找表。

  3. 迭代大量记录时使用@autoreleasepool。

  4. 使用类似于dictionaryOfExistingObjectsByIds或类似于 Tom 在他的答案中引用的方法,从有效导入数据- 一种在其后面有 SQLIN谓词的方法,以显着减少提取次数。阅读 Tom 的回答并参考 Apple 的相应指南,以更好地理解这项技术。


关于这个主题的好读物

objc.io 问题 #4:导入大型数据集

于 2013-08-12T17:11:52.190 回答