Đồng bộ dữ liệu Core Data với Parse Service (Phần 1 + 2)
Bài đăng này đã không được cập nhật trong 3 năm
(Ghi chú: gộp phần 1 và 2, update Swift 2.0, update product entity & service class)
1. Giới thiệu
Ở trong bài viết trước tôi đã trình bày về cách tạo 1 ứng dụng lưu dữ liệu trực tiếp lên Parse Service, việc này giúp cho dữ liệu luôn được đồng bộ giữa nhiều thiết bị, tuy nhiên việc này có hạn chế là chương trình không thể hoạt động nếu không có mạng internet.
Trên thực tế, các chương trình đều lưu dữ liệu trên local, sau đó 1 tiến trình ngầm sẽ thực hiện việc đồng bộ một cách tự động.
Trong khuôn khổ bài viết này tôi sẽ hướng dẫn các bạn nâng cấp chương trình ParseServiceDemo để support việc đồng bộ dữ liệu local.
Việc đăng ký và tạo ứng dụng trên Parse Service các bạn tham khảo ở link trên.
2. Demo App
2.1. Tạo MGParseDemo app
Mở Xcode tạo 1 ứng dụng Single View Application. Đặt tên ứng dụng là MGParseDemo, ngôn ngữ Swift, bỏ chọn Use Core Data.
2.2. Cấu hình Parse SDK
Vào mục download trên Parse để download SDK mới nhất dành cho iOS, (hiện tại là v1.8.1)
Kéo thả Parse.framework và Bolts.framework bạn vừa download vào project trên Xcode. Tích chọn Copy items if needed
Chọn Targets > ParseDemo > Build Phases tab.
Thêm các library sau vào mục Link Binary With Libraries:
- AudioToolbox.framework
- CFNetwork.framework
- CoreGraphics.framework
- CoreLocation.framework
- MobileCoreServices.framework
- QuartzCore.framework
- Security.framework
- StoreKit.framework
- SystemConfiguration.framework
- libz.dylib
- libsqlite3.dylib
- Accounts.framework
- Social.framework
Cập nhật file AppDelegate như sau, thay ApppicationId và clientKey theo app của bạn.
import UIKit
import Parse
import Bolts
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
Parse.enableLocalDatastore()
// Initialize Parse.
Parse.setApplicationId("e3wOeG3tvmL5LryZ6w1imB66WXci7J28SLbX1ud5",
clientKey: "TpvMTzgKm1n9OeCVVJUHnQ53LXJNkLYmlx0cl3LZ")
// [Optional] Track statistics around application opens.
PFAnalytics.trackAppOpenedWithLaunchOptions(launchOptions)
return true
}
}
2.3. Thêm Core Data Model
Thêm Core Data Model vào dự án (New File... > Core Data > Data Model)
Tạo Product Entity như sau:
Tạo NSManagedObject class cho Product Entity (New File... > Core Data > NSManagedObject subclass)
#import "Product.h"
NS_ASSUME_NONNULL_BEGIN
@interface Product (CoreDataProperties)
@property (nullable, nonatomic, retain) NSDate *creation_date;
@property (nullable, nonatomic, retain) NSString *id;
@property (nullable, nonatomic, retain) NSDate *modification_date;
@property (nullable, nonatomic, retain) NSString *name;
@property (nullable, nonatomic, retain) NSNumber *price;
@property (nullable, nonatomic, retain) NSNumber *status;
@end
NS_ASSUME_NONNULL_END
Chương trình sẽ hỏi thêm file bridging header MGParseDemo-Bridging-Header.h
Sau khi đồng ý, ta thêm dòng import file:
#import "Product.h"
2.4. Thêm thư viện MagicalRecord
Ta sử dụng cocoapod để thêm thư viện.
Thêm dòng import vào file bridging header
#import "MagicalRecord.h"
Thêm dòng sau vào hàm didFinishLaunchingWithOptions của AppDelegate
MagicalRecord.setupCoreDataStack()
và hàm applicationWillTerminate
MagicalRecord.cleanUp()
2.5. Tạo Product DTO
Ta sẽ sử dụng DTO để tránh dùng trực tiếp Core Data Entity
import UIKit
enum ProductStatus: Int {
case New
case Updated
case Deleted
}
class ProductDto: NSObject {
var id: String = NSUUID().UUIDString
var creationDate = NSDate()
var modificationDate = NSDate()
var name = ""
var price: Float = 0
var status = ProductStatus.New
}
2.6. Tạo Mapper class
Mapper sẽ có nhiệm vụ map dữ liệu giữa Core Data Entity và DTO object.
import UIKit
class Mapper: NSObject {
class func mapFromProduct(product: Product, toProductDto productDto: ProductDto) {
productDto.id = product.id!
productDto.creationDate = product.creation_date!
productDto.modificationDate = product.modification_date!
productDto.name = product.name!
productDto.price = product.price!.floatValue
productDto.status = ProductStatus(rawValue: product.status!.integerValue)!
}
class func mapFromProductDto(productDto: ProductDto, toProduct product: Product) {
product.id = productDto.id
product.creation_date = productDto.creationDate
product.modification_date = productDto.modificationDate
product.name = productDto.name
product.price = productDto.price
product.status = productDto.status.rawValue
}
class func productDtoFromProduct(product: Product) -> ProductDto {
let productDto = ProductDto()
Mapper.mapFromProduct(product, toProductDto: productDto)
return productDto
}
}
2.7. Tạo Product Repository
Thêm class ProductRepository
, sử dụng để thêm sửa xóa và lấy danh sách Product từ Core Data database.
import UIKit
class ProductRepository: NSObject {
func addProduct(productDto: ProductDto) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
let product = Product.MR_createEntityInContext(context)
Mapper.mapFromProductDto(productDto, toProduct: product)
}
}
func updateProduct(productDto: ProductDto) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
let predicate = NSPredicate(format: "id = '\(productDto.id)'")
let product = Product.MR_findFirstWithPredicate(predicate, inContext: context)
if product != nil {
Mapper.mapFromProductDto(productDto, toProduct: product)
}
}
}
func deleteProductById(id: String) {
MagicalRecord.saveWithBlockAndWait { (context) -> Void in
let predicate = NSPredicate(format: "id = '\(id)'")
let product = Product.MR_findFirstWithPredicate(predicate, inContext: context)
if product != nil {
Product.MR_deleteAllMatchingPredicate(predicate, inContext: context)
}
}
}
func isProductExistedForId(id: String) -> Bool {
let predicate = NSPredicate(format: "id = '\(id)'")
let product = Product.MR_findFirstWithPredicate(predicate)
if product != nil {
return true
}
return false
}
func count() -> Int {
return Int(Product.MR_countOfEntities())
}
func getAllProducts(includeDeletedProduct: Bool = false) -> [ProductDto] {
let predicate = NSPredicate(format: "status != '\(ProductStatus.Deleted.rawValue)'")
var products: [Product]!
if includeDeletedProduct {
products = Product.MR_findAll() as! [Product]
}
else {
products = Product.MR_findAllWithPredicate(predicate) as! [Product]
}
var productDtos = [ProductDto]()
for product in products {
let productDto = Mapper.productDtoFromProduct(product)
productDtos.append(productDto)
}
return productDtos
}
func mostRecentUpdatedDate() -> NSDate? {
if let products = Product.MR_findAllSortedBy("modification_date", ascending: false) as? [Product] {
if products.count > 0 {
return products[0].modification_date
}
}
return nil
}
func getProductsUpdatedAfterDate(date: NSDate) -> [ProductDto] {
let predicate = NSPredicate(format: "modification_date > %@", date)
let products = Product.MR_findAllSortedBy("modification_date", ascending: true, withPredicate: predicate) as! [Product]
var productDtos = [ProductDto]()
for product in products {
let productDto = Mapper.productDtoFromProduct(product)
productDtos.append(productDto)
}
return productDtos
}
}
2.8. Tạo Product Parse Client
Thêm class ProductParseClient
, sử dụng để thêm sửa xóa và lấy danh sách Product từ Parse Service database.
import UIKit
import Parse
class ProductParseClient: NSObject {
func addProduct(product: ProductDto) {
let obj = PFObject(className: "Product")
obj.setObject(product.id, forKey: "id")
obj.setObject(product.creationDate, forKey: "creationDate")
obj.setObject(product.modificationDate, forKey: "modificationDate")
obj.setObject(product.name, forKey: "name")
obj.setObject(product.price, forKey: "price")
obj.setObject(product.status.rawValue, forKey: "status")
do {
try obj.save()
}
catch {
print("Save error!")
}
}
func updateProduct(product: ProductDto) {
let query = PFQuery(className: "Product")
query.whereKey("id", equalTo: product.id)
do {
let objects = try query.findObjects()
for obj in objects {
obj.setObject(product.modificationDate, forKey: "modificationDate")
obj.setObject(product.name, forKey: "name")
obj.setObject(product.price, forKey: "price")
obj.setObject(product.status.rawValue, forKey: "status")
try obj.save()
}
}
catch {
print("Query error!")
}
}
func isProductExistedForId(id: String) -> Bool {
let query = PFQuery(className: "Product")
query.whereKey("id", equalTo: id)
do {
let objects = try query.findObjects()
return objects.count > 0
}
catch {
print("Query error!")
}
return false
}
func deleteProduct(productID: String) {
let query = PFQuery(className: "Product")
query.whereKey("id", equalTo: productID)
do {
let objects = try query.findObjects()
for obj in objects {
try obj.delete()
}
}
catch {
print("Delete object error!")
}
}
func count() -> Int {
let query = PFQuery(className: "Product")
var count = -1
var error: NSError?
count = query.countObjects(&error)
return count
}
func getAllProducts() -> [ProductDto] {
var products = [ProductDto]()
let query = PFQuery(className: "Product")
do {
let objects = try query.findObjects()
products += productDtosFromPFObjects(objects)
}
catch {
print("Get products error!")
}
return products
}
private func productDtosFromPFObjects(objects: [PFObject]) -> [ProductDto] {
var products = [ProductDto]()
for obj in objects {
let product = ProductDto()
product.id = obj.objectForKey("id") as! String
product.name = obj.objectForKey("name") as! String
product.price = obj.objectForKey("price") as! Float
product.creationDate = obj.objectForKey("creationDate") as! NSDate
product.modificationDate = obj.objectForKey("modificationDate") as! NSDate
product.status = ProductStatus(rawValue: obj.objectForKey("status") as! Int)!
products.append(product)
}
return products
}
func mostRecentUpdatedDate() -> NSDate? {
let query = PFQuery(className: "Product")
query.orderByDescending("modificationDate")
do {
let obj = try query.getFirstObject()
return obj.objectForKey("modificationDate") as? NSDate
}
catch {
print("Get most recent updated date error!")
}
return nil
}
func getProductsUpdatedAfterDate(date: NSDate) -> [ProductDto] {
var products = [ProductDto]()
let query = PFQuery(className: "Product")
query.whereKey("modificationDate", greaterThan: date)
do {
let objects = try query.findObjects()
products += productDtosFromPFObjects(objects)
}
catch {
print("Get products error!")
}
return products
}
}
2.9. Tạo Product Service
ProductService
sử dụng ProductRepository
để thêm sửa xóa và lấy danh sách Product từ Core Data database.
Chú ý hàm
deleteProduct
, chúng ta không thực sự xóa product khỏi database mà chỉ update thuộc tính status của product thành Deleted, việc này sẽ giúp cho việc đồng bộ dữ liệu được đơn giản hơn.
import UIKit
class ProductService: NSObject {
let productRepository = ProductRepository()
func addProduct(productDto: ProductDto) {
productRepository.addProduct(productDto)
}
func updateProduct(productDto: ProductDto) {
productDto.modificationDate = NSDate()
productDto.status = ProductStatus.Updated
productRepository.updateProduct(productDto)
}
func addOrUpdateProduct(productDto: ProductDto) {
if isProductExistedForId(productDto.id) {
productRepository.updateProduct(productDto)
}
else {
productRepository.addProduct(productDto)
}
}
func deleteProduct(productDto: ProductDto) {
productDto.modificationDate = NSDate()
productDto.status = ProductStatus.Deleted
productRepository.updateProduct(productDto)
}
func isProductExistedForId(id: String) -> Bool {
return productRepository.isProductExistedForId(id)
}
func getAllProducts(includeDeletedProduct: Bool = false) -> [ProductDto] {
return productRepository.getAllProducts(includeDeletedProduct)
}
func mostRecentUpdatedDate() -> NSDate? {
return productRepository.mostRecentUpdatedDate()
}
func getProductsUpdatedAfterDate(date: NSDate) -> [ProductDto] {
return productRepository.getProductsUpdatedAfterDate(date)
}
}
2.10. Tạo Product Parse Service
ProductParseService
sử dụng ProductParseClient
để thêm sửa xóa và lấy danh sách Product từ Parse Service.
import UIKit
class ProductParseService: NSObject {
let productParseClient = ProductParseClient()
func mostRecentUpdatedDate() -> NSDate? {
return productParseClient.mostRecentUpdatedDate()
}
func getProductsUpdatedAfterDate(date: NSDate) -> [ProductDto] {
return productParseClient.getProductsUpdatedAfterDate(date)
}
func addProduct(product: ProductDto) {
productParseClient.addProduct(product)
}
func addOrUpdateProduct(product: ProductDto) {
if productParseClient.isProductExistedForId(product.id) {
productParseClient.updateProduct(product)
}
else {
productParseClient.addProduct(product)
}
}
func getAllProducts() -> [ProductDto] {
return productParseClient.getAllProducts()
}
}
2.11. Tạo Sync Service
SyncService
có nhiệm vụ đồng bộ dữ liệu giữa Core Data database (local) và Parse Service database.
Hàm sycn
sẽ lấy thời gian cập nhật mới nhất của local database và parse database và so sánh 2 thời gian này, tùy từng trường hợp mà sẽ update dữ liệu local và dữ liệu parse.
Cơ chế đồng bộ như sau:
- Bước 1: Lấy thời gian cập nhật mới nhất của local database và parse database
- Bước 2: Kiểm tra 2 thời gian này
- Nếu thời gian local = nil: ta sẽ lấy toàn bộ dữ liệu từ parse database và cập nhật vào local
- Nếu thời gian parse database = nil: ta sẽ lấy toàn bộ dữ liệu từ local và upload lên parse service
- Nếu cả 2 thời gian này khác nil thì ta sẽ chuyển sang bước 3
- Bước 3: So sánh thời gian local và parse
- Nếu thời gian local > parse: ta sẽ lấy toàn bộ product của local tính từ thời gian mới nhất của parse và update lên parse service.
- Nếu product chưa có trên parse (tính theo id) thì sẽ thêm mới
- Nếu đã có thì ta update dữ liệu
- Nếu thời gian parse > local: ta sẽ lấy toàn bộ product của parse, tính từ thời gian cập nhật local và update vào local.
- Nếu product chưa có trên local (tính theo id) thì sẽ thêm mới
- Nếu đã có thì ta update dữ liệu
- Nếu thời gian local > parse: ta sẽ lấy toàn bộ product của local tính từ thời gian mới nhất của parse và update lên parse service.
import UIKit
class SyncService: NSObject {
let productService = ProductService()
let productParseService = ProductParseService()
func sync() {
let localUpdatedDate: NSDate? = productService.mostRecentUpdatedDate()
let parseUpdatedDate: NSDate? = productParseService.mostRecentUpdatedDate()
if localUpdatedDate == nil {
if parseUpdatedDate != nil {
let products = productParseService.getAllProducts()
for product in products {
productService.addProduct(product)
}
}
}
else {
if parseUpdatedDate != nil {
// localUpdatedDate > parseUpdatedDate
if localUpdatedDate!.compare(parseUpdatedDate!) == NSComparisonResult.OrderedDescending {
let products = productService.getProductsUpdatedAfterDate(parseUpdatedDate!)
for product in products {
productParseService.addOrUpdateProduct(product)
}
}
else {
let products = productParseService.getProductsUpdatedAfterDate(localUpdatedDate!)
for product in products {
productService.addOrUpdateProduct(product)
}
}
}
else {
let products = productService.getAllProducts(true)
for product in products {
productParseService.addProduct(product)
}
}
}
}
}
2.12. Tạo ProductViewController
ProductViewController
cho phép nhập tên product và giá, phục vụ cho việc thêm mới và sửa product.
import UIKit
protocol ProductViewControllerDelegate: class {
func productViewControllerDidAdd(product: ProductDto)
func productViewControllerDidUpdate(product: ProductDto)
}
class ProductViewController: UITableViewController {
@IBOutlet weak var nameTextField: UITextField!
@IBOutlet weak var priceTextField: UITextField!
var product: ProductDto!
weak var delegate: ProductViewControllerDelegate?
override func viewDidLoad() {
super.viewDidLoad()
if product != nil {
nameTextField.text = product.name
priceTextField.text = "\(product.price)"
}
nameTextField.becomeFirstResponder()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
@IBAction func onSaveButtonClicked(sender: AnyObject) {
var isUpdatingProduct = true
if product == nil {
product = ProductDto()
isUpdatingProduct = false
}
product.name = nameTextField.text!
let string = NSString(string: priceTextField.text!)
product.price = string.floatValue
product.modificationDate = NSDate()
if !isUpdatingProduct {
delegate?.productViewControllerDidAdd(product)
}
else {
delegate?.productViewControllerDidUpdate(product)
}
dismissView()
}
@IBAction func onCancelButtonClicked(sender: AnyObject) {
dismissView()
}
private func dismissView() {
self.dismissViewControllerAnimated(true, completion: nil)
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 2
}
}
2.13. Tạo ProductListViewController
ProductListViewController
có các chức năng hiển thị danh sách product, thêm, sửa, xóa và đồng bộ product.
import UIKit
class ProductListViewController: UITableViewController, ProductViewControllerDelegate {
var productService = ProductService()
var products: [ProductDto]!
@IBOutlet weak var addButton: UIBarButtonItem!
var syncButton: UIBarButtonItem!
let syncService = SyncService()
override func viewDidLoad() {
super.viewDidLoad()
syncButton = UIBarButtonItem(barButtonSystemItem: UIBarButtonSystemItem.Refresh, target: self, action: Selector("onSyncButtonClicked:"))
self.navigationItem.rightBarButtonItem = self.editButtonItem()
self.navigationItem.leftBarButtonItems = [ addButton, syncButton ]
products = productService.getAllProducts()
}
func onSyncButtonClicked(sender: AnyObject) {
UIApplication.sharedApplication().networkActivityIndicatorVisible = true
syncService.sync()
UIApplication.sharedApplication().networkActivityIndicatorVisible = false
products = productService.getAllProducts()
tableView.reloadData()
let alertView = UIAlertView(title: "Sync completed!", message: nil, delegate: nil, cancelButtonTitle: "OK")
alertView.show()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
@IBAction func onAddButtonClicked(sender: AnyObject) {
self.performSegueWithIdentifier("showProduct", sender: nil)
}
// MARK: - Table view data source
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return products.count
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return 44
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
if self.editing {
let product = products[indexPath.row]
self.performSegueWithIdentifier("showProduct", sender: product)
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("ProductCell", forIndexPath: indexPath)
let product = products[indexPath.row]
cell.textLabel?.text = product.name
cell.detailTextLabel?.text = "$\(product.price)"
return cell
}
override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
return true
}
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
if editingStyle == .Delete {
let product = products[indexPath.row]
productService.deleteProduct(product)
var row = -1
for (index, value) in products.enumerate() {
if value.id == product.id {
row = index
break
}
}
if row != -1 {
products.removeAtIndex(row)
}
tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
}
}
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "showProduct" {
let controller = (segue.destinationViewController as! UINavigationController).topViewController as! ProductViewController
if let product = sender as? ProductDto {
controller.product = product
}
controller.delegate = self
}
}
// MARK: - ProductViewControllerDelegate
func productViewControllerDidAdd(product: ProductDto) {
productService.addProduct(product)
products.append(product)
tableView.insertRowsAtIndexPaths([NSIndexPath(forRow: products.count - 1, inSection: 0)], withRowAnimation: UITableViewRowAnimation.Automatic)
}
func productViewControllerDidUpdate(product: ProductDto) {
productService.updateProduct(product)
var row = -1
for (index, value) in products.enumerate() {
if value.id == product.id {
row = index
break
}
}
if row != -1 {
tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: row, inSection: 0)], withRowAnimation: UITableViewRowAnimation.Automatic)
}
}
}
2.14. Chạy demo
Test các trường hợp sau:
Trên app:
- Thêm product ở local
- Xóa product ở local
- Xóa app, chạy lại app
- Cập nhật product trên app
Trên web:
- Xóa product trên web
- Update thông tin product trên web (nhớ update cả modification date)
Sau mỗi bước trên các bạn hãy nhấn nút Sync trên app và kiểm tra sự đồng bộ dữ liệu trên local và web.
3. Kết luận
Như vậy các bạn đã hoàn thành việc đồng bộ dữ liệu local và Parse Service. Tuy nhiên việc đồng bộ này không tiến hành ngầm mà phải "nhấn nút" Sync. Thực tế các bạn phải thực hiện việc này 1 cách tự động và đưa tác vụ sync vào background thread.
Thuật toán đồng bộ trên có 1 sơ hở đó là khi local không được update dữ liệu mới nhất của Parse Service nhưng vẫn tiến hành update dữ liệu ở local (thêm, sửa, xóa). Các bạn có thể khống chế bằng cách nếu không update dữ liệu với Parse thì không cho update dữ liệu local, hoặc lưu thời gian đồng bộ vào settings của app và khi đồng bộ phải xét tới cả thời gian này.
Cảm ơn bạn đã theo dõi.
Các bạn có thể tham khảo source code tại đây
All rights reserved