Wendy 9.0.1

Wendy 9.0.1

Levi Bostian维护。



Wendy 9.0.1

  • Levi Bostian

Version License Platform Swift 5.0.x

Wendy

简化离线首次使用的iOS应用程序的创建。轻松地将您的离线设备存储与远程云存储同步。在构建离线首次使用的移动应用程序时,有很多用途需要考虑。Wendy帮您处理好所有这些!

project logo. A picture of a person with long red hair.

阅读Wendy的官方公告了解更多关于它的功能以及为什么要使用它。

Android开发者?查看Wendy的Android版本!

公告 - 版本1.0

版本1.0已宣布并处于开发中。查看问题了解更多信息。1.0完成之前,预1.0代码将处于维护模式。预1.0已用在我今天的生产应用中,但已警告您,1.0发布时可能会有大量破坏性更改。

Wendy是什么?

Wendy是一个iOS库,旨在帮助您创建离线首次使用的应用程序。使用Wendy定义同步任务,然后Wendy会定期运行这些任务,以确保您的应用程序的设备离线数据与它的在线远程存储保持同步。

Wendy是一个先进先出的任务执行器。您一个接一个地给它任务。Wendy将这些任务持久化到存储中。然后,当Wendy确定运行您任务的好时机时,它将调用您的任务同步函数以执行同步。Wendy将依次处理所有可用的任务,使它们成功或失败并再次尝试。

注意:Wendy 目前处于 alpha 阶段。API 可能会发生变化,并且在未来版本中可能会有破坏性的更改。它目前被用于生产应用中。请按需使用 Wendy 的最新版本,但对于今后版本可能需要更新您的代码库做好准备。

为什么选择 Wendy?

在创建离线优先的移动应用时,您需要在代码中完成两个任务。1. 将数据持久化到用户的 iOS 设备存储,2. 将用户的存储与远程在线存储同步。

Wendy 帮助您完成第 2 个任务。您定义本地存储如何与远程存储同步,Wendy 将负责在适当的时候定期运行这些任务。

Wendy 当前具有以下功能

  • Wendy 使用 iOS 背景检索调度器 API 定期运行任务,以在不过度消耗用户电池的情况下保持数据同步。
  • Wendy 没有固定的观点。您可以选择任何方法与其远程存储同步数据,也可以选择任何方法将数据存储在本地设备上。Wendy 与您的现有工作流程协同工作。在本地使用 Core Data 存储用户数据,并使用 Rails API 进行云端存储。在本地使用 Realm 存储用户数据,并使用 Parse 服务器进行云端存储。仅使用 NSUserDefaults 和 GraphQL。无论您想要什么,Wendy 都与之兼容。
  • 动态允许和禁止任务在运行时同步。Wendy 以 FIFO 风式处理其任务。当 Wendy 即将运行某个特定任务时,它会始终询问任务是否能够运行。
  • 为任务标记手动运行,而不是从 Wendy 自动运行。这允许您使用相同的 Wendy API 定义所有同步任务,但 Wendy 将不会尝试定期自动运行这些任务。
  • 将任务分组,确保它们从头到尾顺序运行(并成功)。
  • Wendy 还附带一个错误报告器,用于报告用户需要修复以使任务成功的错误。
  • Wendy 处理构建离线优先移动应用可能发生的所有用例。“如果这个任务成功了但这个任务失败了怎么办?当网络不稳定并且有几个任务失败但应该重试时会发生什么?如果这个任务需要成功才能在该 API 上成功执行这个任务怎么办?”Wendy 将处理所有这些,您定义行为,Wendy 确信它可以运行该任务并获得成功时将负责运行它。

安装

Wendy-iOS 通过 CocoaPods 提供。要安装它,只需将以下行添加到您的 Podfile 中

pod 'Wendy', '~> version-here'

(请将 version-here 替换为 Version)

注意:建议在您的Podfile中指定版本号(如上所示),因为Wendy目前正在开发alpha阶段。API将可能发生变化,在beta版和稳定版发布前可能会出现破坏性更改。当前最新版本为:Version

入门

在此入门指南中,我们将通过一个例子来让您跟进。假设您正在构建一个购物清单应用程序。我们可以称之为Grocery List

首先,创建一个PendingTasksFactory子类来存储您应用程序的所有Wendy PendingTask。一开始它相当空白,但我们会稍后添加更多内容。(我计划将来取消这一要求。欢迎提交PR)😄)

import Wendy

class GroceryListPendingTasksFactory: PendingTasksFactory {

    func getTask(tag: PendingTask.Tag) -> PendingTask {
        switch tag {      
        default: 
            fatalError("Forgot case with tag: \(tag)")
        }
    }

}

将以下代码添加到您的AppDelegateapplication(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool函数中

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {  
    Wendy.setup(tasksFactory: GroceryListPendingTasksFactory())
    #if DEBUG
    WendyConfig.debug = true
    #endif

    return true
}

(如果您不知道如何在您的应用程序中使#if DEBUG工作,请参看这里)

Wendy现在已配置。现在是时候使用它了!

对于您需要与远程云存储同步的每个单独的任务,您定义一个PendingTask子类。

在我们的购物清单应用程序中,我们希望允许用户创建新的购物项目。每次用户创建一个新的购物清单条目时,我们不想在执行API调用时显示进度条,告诉他们“正在保存购物清单条目...”。我们希望能够立即保存该购物清单条目并与云存储同步,这样用户就可以继续他们的生活(难道你看不到你的应用商店评论直线上升?).

让我们创建我们的第一个创建新购物条目的PendingTask子类。

import Wendy

class CreateGroceryListItemPendingTask: PendingTask {

    static let tag: Tag = String(describing: CreateGroceryListItemPendingTask.self)

    static let groceryStoreItemTextTooLongErrorId = "GROCERY_STORE_ITEM_TEXT_TOO_LONG"

    var taskId: Double?
    var dataId: String?
    var groupId: String?
    var manuallyRun: Bool = false
    var createdAt: Date?

    convenience init(groceryStoreItemId: Int) {
        self.init()
        self.dataId = String(groceryStoreItemId)
    }

    func isReadyToRun() -> Bool {
        return true
    }

    func runTask(complete: @escaping (Error?) -> Void) {
        // Here, instantiate your dependencies, talk to your DB, your API, etc. Run the task.
        // After the task succeeds or fails, return to Wendy the result.

        let groceryStoreItem = localDatabase.queryGroceryStoreItem(self.dataId)

        performApiCall(groceryStoreItem, complete: { apiCallResult in
            if let apiError = apiCallResult.error {
                // There was an error. Parse the error and decide what to do from here.

                // If it's an error that deserves the attention of your user to fix, make sure and record it with Wendy.
                // If the error is a network error, for example, that does not require the user's attention to fix, do *not* record an error to Wendy.
                // Wendy will not run your task if there is a recorded error for it. Record an error, prompt your user to fix it, then resolve it ASAP so it can run.
                Wendy.shared.recordError(taskId: self.taskId, humanReadableErrorMessage: "Grocery store item too long. Please shorten it up for me.", errorId: groceryStoreItemTextTooLongErrorId)
            } 
            
            complete(apiCallResult.error)            
        })
    }

}

每次您创建一个新的PendingTask子类时,您需要将其添加到您创建的PendingTasksFactory中。您的GroceryListPendingTasksFactory现在应该看起来像这样

import Wendy

class GroceryListPendingTasksFactory: PendingTasksFactory {

    func getTask(tag: PendingTask.Tag) -> PendingTask {
        switch tag {
        case CreateGroceryListItemPendingTask.tag: return CreateGroceryListItemPendingTask()
        default: 
            fatalError("Forgot case with tag: \(tag)")
        }
        }
    }

}

就要完成了。

让我们看一下在您的购物清单应用程序中,当用户想要在应用程序中创建一个新的购物清单项时,您所编写的代码。

func createNewGroceryStoreItem(itemName: String) {
    // First thing you need to do to make a mobile app offline-first is to save it to the device's storage.
    // Below, we are saving to a `localDatabase`. Whatever that is. It could be whatever you wish. Core Data, Sqlite, Realm, Keychain, NSUserDefaults, whatever you decide to use works. After we save to the database, we probably get an ID back to reference that piece of data in the database. This ID could be the key in NSUserDefaults, the database row ID, it doesn't matter. Simply some way to identify that piece of data *to query later* in your PendingTask.
    let id: Int = localDatabase.createNewGroceryStoreItem(itemName)

    // We will now create a new `CreateGroceryListItemPendingTask` pending task instance and give it to Wendy.
    let pendingTaskId: Double = Wendy.shared.addTask(CreateGroceryListItemPendingTask(groceryStoreItemId: id))

    // When you add a task to Wendy, you get back an ID for that new `PendingTask`. It's your responsibility to save that ID (or ignore it). It's best practice to save that ID with the data that this `PendingTask` links to. In our example here, the grocery store item in our localDatabase is where we should save the ID.
    localDatabase.queryGroceryStoreItem(id).pendingTaskId = pendingTaskId

    // The reason you may want to save the ID of the `PendingTask` is to assert that it runs successfully. Also, you can show in the UI of your app the syncing status of that data to the user. This is all optional, but recommended for the best user experience.
    WendyConfig.addTaskStatusListenerForTask(task.taskId!, listener: self) // View the extension code below.     
}

extension View: PendingTaskStatusListener {

    func running(taskId: Double) {
        self.text = "Running"
    }

    func complete(taskId: Double, successful: Bool) {
        self.text = successful ? "Success!" : "Failure"
    }

    func skipped(taskId: Double, reason: ReasonPendingTaskSkipped) {
        self.text = "Skipped"
    }

    func errorRecorded(taskId: Double, errorMessage: String?, errorId: String?) {
        self.text = "Error recorded: \(errorMessage!)"
    }

    func errorResolved(taskId: Double) {
        self.text = "Error resolved"
    }

}

非常最后一步。让Wendy定期运行您的任务。

在XCode中,按照以下步骤启用您应用程序的背景获取功能

In XCode go to your project settings tab. Then the capabilities section. Turn on Background Modes and then check the box Background fetch

现在,在您的AppDelegate中,您需要从后台获取功能中运行Wendy。以下是一个示例

func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    let backgroundFetchResult = Wendy.shared.performBackgroundFetch()
    completionHandler(backgroundFetchResult.backgroundFetchResult)
}

唯一的要求是调用Wendy.shared.performBackgroundFetch()。您可以选择忽略Wendy此函数的结果,如果您需要在这个函数中运行更多函数。如果您决定这样做,Wendy会为您解析后台获取结果:backgroundFetchResult.backgroundFetchResult

完成了!Wendy负责其他一切。Wendy会立即尝试运行您的任务,但如果您离线或网络连接不佳,Wendy会等待并在稍后再次尝试。

在使用Wendy时,有一些最佳实践文档(请查看这里)。阅读它可以帮助您了解Wendy为何以这种方式工作。虽然该文档的代码是Android代码,但它关注的是最佳实践,因此您应该能够理解它,直到我设置了一个更好的“通用”文档。😄.

集合

在您的移动应用中考虑以下场景

  1. 您的应用在应用内显示了用户的请求列表。
  2. 用户决定接受请求列表中的一个请求。
  3. 您的代码执行
  • 创建一个待处理的Wendy任务以接受这个请求。
  • 从缓存中删除请求,以便请求不再在应用列表中显示。
  1. 由于某种原因,接受请求的待处理任务遇到错误。
  2. 您的应用程序用户尝试通过执行针对已更新请求列表的HTTP请求来刷新应用内的请求列表。

如果步骤5成功,可能会发生什么?我会猜测,由于Wendy待处理任务尚未成功运行,您的应用程序的远程数据库尚未知道已经接受的请求。因此,如果步骤5成功,则应用会接收到包含已接受请求的请求对象列表,并将其插入应用缓存。这会导致您的应用显示已接受的旧请求。

这不好!这可能会(1)使您的应用程序处于不稳定状态,并且(2)导致用户困惑,因为他们已接受了请求。

为了应对这种情况,使用Wendy集合。让我们深入了解。

集合是一种将不同类型的待处理任务分组的方法。虽然PendingTask的groupId旨在为同一类型的PendingTask实例分组,但集合将所有类型的PendingTask分组在一起。

以我们上面的示例为例,假设您有一个批准朋友请求和拒绝朋友请求的Wendy待处理任务

class AcceptFriendRequestPendingTask: PendingTask {    
    static let tag: Tag = "Accept friend request"
    ...
}
class DeclineFriendRequestPendingTask: PendingTask {    
    static let tag: Tag = "Decline friend request"
    ...
}

现在,这两个待处理任务有一个共同点。它们都属于应用内某个用户的请求。如果您的应用程序的用户想要刷新他们的请求列表,所有审批和拒绝请求的待处理任务都应该已成功运行,以避免之前提到的不可靠问题。

因此,让我们定义集合。

  1. 可以使用Wendy.setup()函数定义集合。集合 simply是一条将ID和一组PendingTask关联起来的映射。
Wendy.setup(tasksFactory: AppPendingTasksFactory(), collections: [
    "Friend Requests list": [
        AcceptFriendRequestPendingTask.tag,
        DeclineFriendRequestPendingTask.tag
    ],
    "Update user profile": [
        UploadProfilePhotoPendingTask.tag,
        UpdateEmailAddressPendingTask.tag
    ],
    "Friends list": [
        AcceptFriendRequestPendingTask.tag
    ]
])

请注意,您可以如何将PendingTask添加到多个集合中。

提示:字符串容易出错。最好使用enum

import Wendy

enum WendyCollectionIds: CollectionId {
    case friendRequestsList 
    case updateUserProfile
    case friendsList
}

Wendy.setup(tasksFactory: AppPendingTasksFactory(), collections: [
    WendyCollectionIds.friendRequestsList.rawValue: [
        ...
    ],
    WendyCollectionIds.updateUserProfile.rawValue: [
        ...
    ],
    WendyCollectionIds.friendsList.rawValue: [
        ...
    ]
])

如果Wendy找不到通过ID指定的集合,将会崩溃。为了避免这些错误,请避免使用字符串。

  1. 现在您已经向Wendy告知了您的集合,我们可以运行该集合中所有的PendingTask
Wendy.shared.runTasks(filter: RunAllTasksFilter.collection(id: WendyCollectionIds.friendRequestsList.rawValue)) { result in        
}

注意:如果PendingTask被标记为手动运行,则runTasks()不会运行该任务。

  1. 现在,为了防止您的应用进入不稳定状态,在您运行获取好友请求列表的HTTP请求之前,请运行挂起任务集合。
Wendy.shared.runTasks(filter: RunAllTasksFilter.collection(id: WendyCollectionIds.friendRequestsList.rawValue)) { result in        
    if let pendingTasksFirstFailure = result.firstFailedResult {
        // Do not run the HTTP request to get list of friend requests. The app's state could become unstable. 
        // Note: A failure could mean the pending task failed to run or was skipped. Don't assume it was a failure! 
    } else 
        // Go ahead and run the HTTP request to get the list of friend requests! All pending tasks in the collection have run successfully. 
    }
}

因为我们总是在执行HTTP请求之前确保挂起任务正在成功运行,所以您应用的状态永远不会变得不稳定。

清除数据

如果您遇到用户从您的应用中注销,或删除设备上所有应用数据的场景,您可以清除Wendy的所有数据。

Wendy.shared.clear()

注意:如果在调用clear()PendingTask任务正在执行,该任务将完成执行。

测试

Wendy在设计时考虑了单元测试/集成测试/UI测试。以下是使用Wendy进行测试的方法。

针对PendingTask实现编写单元测试

PendingTask的实现很容易编写单元测试。它仅仅是一个协议。您可以使用依赖注入单元测试,例如,测试PendingTask的所有函数。

针对依赖于Wendy类的代码编写单元测试

在编写针对Wendy类如PendingTaskErrorPendingTasksRunnerResult的测试时,Wendy允许您利用添加到这些内部类的方便的.testing.属性来创建这些内部类的实例。

以下是一些例子

PendingTaskError.testing.get(pendingTask: PendingTask, errorId: String, errorMessage: String, createdAt: Date)
PendingTasksRunnerResult.testing.result(from results: [TaskRunResult])
WendyUIBackgroundFetchResult.testing.get(runnerResult: PendingTasksRunnerResult)

围绕Wendy编写集成测试

即将推出!

你可能已经可以这样做,但是尚未经过测试。一个好的开始是每次测试之前都清除Wendy,并且像平常一样使用它。看看它能带你去哪里。在遇到问题时,请报告问题。

示例

要运行示例项目,首先从 Example/ 目录中克隆仓库,然后运行 pod install。然后,打开XCode并运行项目。

安装模板文件

Wendy附带了几个XCode模板文件,可以从File > New File菜单快速创建PendingTaskPendingTaskFactory

您只需运行此bash脚本即可在XCode模板目录中安装脚本

./Pods/Wendy/Templates/install_templates.sh

如果XCode当前已在您的计算机上打开,请重新启动XCode。

然后,下次您打开XCode并转到“新文件”时,您将看到一个名为“Wendy”的部分,其中包含文件模板!

文档

Wendy目前没有完整的代码文档。计划通过jazzy在不久的将来生成完整的文档。

在此之前,最好的做法是

  • 阅读此README以了解如何开始。
  • Wendy-Android为其创建了完整文档。如果你想知道特定函数的工作原理,你可能能在那里学到。 警告:Wendy-Android和Wendy-iOS将尽可能相互保持更新。当一个版本修复了错误,另一个版本也会同样修复错误。然而,在这种情况下,可能需要一到两天才能通过贡献者进行同步。因此,文档可能在库之间有点不一致。
  • 在Twitter上联系该作者Levi.

配置Wendy

使用类 WendyConfig 来配置Wendy的行为。

  • 为Wendy任务运行器注册监听器。
WendyConfig.addTaskRunnerListener(listener: listener)
  • 为特定的Wendy PendingTask 注册监听器。
WendyConfig.addTaskStatusListenerForTask(taskId: pendingTaskId, listener: listener)
  • 当Wendy在开发中运行时,让Wendy记录调试语句。
WendyConfig.debug = true # default is false.

我建议你做以下操作

#if DEBUG
WendyConfig.debug = true
#endif

作者

Levi Bostian image

许可协议

Wendy-iOS遵循MIT许可协议。查看LICENSE文件以获取更多信息。

贡献

Wendy接受pull请求。查看问题列表了解我计划解决的问题。如果你愿意以这种方式做出贡献,请查看这些列表。

想为Wendy添加功能? 在你决定花费大量时间添加库的功能之前,请先创建一个问题,说明你想添加的内容。这可能会节省你一些时间,以防你的目的与Wendy的使用案例不适合。

遵循以下步骤在您的机器上编译Wendy项目以进行贡献!

  • 在XCode中打开Example/Wendy.xcworkspace
  • 在XCode中编译项目。

鸣谢

封面照片由Allef Vinicius在Unsplash提供