Wstęp

Jak pewnie wielu z Was – drodzy czytelnicy – zauważyło, jestem ogromnym zwolennikiem Core Data jako warstwy persystencji. Może o tym świadczyć choćby fakt, że to właśnie ten framework wziąłem na warsztat podczas swojej prezentacji na tegorocznym AltConf near WWDC w San Jose. Jeśli jesteś ciekawy, jak w szybki i prosty sposób usprawnić swój Core Data Stack, sprawdź ten lightning talk! 👍🇺🇸.

Tak! Lubię Core Data z wielu powodów. Po pierwsze uno ➡️ szybkość. Ten framework jest po prostu wydajny. I choć nie trudno zgadnąć, że w przypadku zużytych zasobów pamięci SQLite będzie wydajniejszy, o tyle Core Data bije do na głowę w przypadku szybkości odczytu rekordów (tzw. Fetch request). Core Data jest jest też szybka w implementacji. Podstawowe obiekty możemy w prosty sposób zapisywać w naszej bazie danych poprzez napisanie kilku linijek kodu. Dodatkowo nie musimy pamiętać o otwieraniu połączeń, zamykaniu, o transakcjach itp.

Po drugie uno ➡️ Core Data jest iOS native. Mamy tutaj do czynienia z rozwiązaniem, które jest dedykowane do aplikacji Mac oraz iOS. I choć sam framework nadal ma dużo naleciałości z Objective-C, w którym to jest pisany, to przy odrobinie Swift-like kodu jest naprawdę bardzo przyjemny w użyciu.

Co więcej – Core Data to także znakomity sposób na cache’owanie danych w naszej aplikacji. A to dlatego, że możemy tą warstwę w praktycznie nieograniczony sposób dostosowywać do naszych potrzeb, np. Poprzez dodawanie do niej… swoich typów. A pomaga nam w tym bohater dzisiejszego odcinka – ValueTransformer!

Typ Transformable

Na początku zaznaczam, że nie jest to wpis wyjaśniający od zera „jak postawić projekt w Core Data” i wymaga on podstawowej znajomości tego framework’a, żeby dobrze zrozumieć całe przedsięwzięcie.

Do rzeczy. Kiedy tworzymy sobie encję w Core Data, to możemy korzystać z pewnego zbioru typów, takich jak np.:

  • String
  • Date
  • Boolean
  • Int16
  • Int32
  • Binary Data

Jednak co w przypadku, kiedy chcemy aby obiekty naszej encji (w nomenklaturze bazodanowej zwane rekordami) przechowywały np. tablicę z elementami typu String?

No cóż – możemy wtedy użyć typu Transformable. Następnie w Attribute Inspektorze należy zadeklarować, jakiego typu będzie dana zmienna:

Wybór typu Transformable

Jeśli teraz wygenerujemy sobie plik z klasą, to zobaczymy że typem dla zmiennej array będzie właśnie tablica z elementami typu String:

Generowanie pliku z klasą

import Foundation
import CoreData


extension Entity {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Entity> {
        return NSFetchRequest<Entity>(entityName: "Entity")
    }

    @NSManaged public var array: [String]?

}

NOTE: Dla jasności – zawsze generuję sobie klasę dla encji. Dzięki temu od razu widzę, że mam do czynienia z typową klasą Swift’ową, jest mi nią łatwiej zarządzać, rozszerzać itp.

I w prządku! Czas na coś bardziej… wyrafinowanego 😎.

UIImage jako typ zmiennej

Załóżmy zatem, że chcemy dla encji User przechowywać pewne informacje o użytkowniku, takie jak nazwę, czy avatar w postaci obrazka. Hm…, pierwsze co od razu przychodzi na myśl 🤔 obrazek to coś, co może być zaprezentowane w postaci binarnej. Zatem możemy dodać do naszej encji po prosty zmienną o typie binarnym:

Zmienna avatar jako Binary Data

Meh… wszystko fajnie, ale w tej chwili przy każdej próbie zapisu i odczytu muszę pamiętać, aby ten obrazek zamieniać w te i na zad w postać binarną (na np. Data lub NSData). Uciążliwe i jak na Swift – mało eleganckie.

I tutaj na białym koniu wjeżdża ValueTransformer. Formalnie jest to abstrakcyjna klasa dziedzicząca z NSObject, która służy do transformacji zmiennych jednego typu w inne. Brzmi jak doskonałe zastosowanie do naszego przykładu. Tworzę zatem dla swoich potrzeb klasę ImageTransformer, która to dziedziczyć będzie właśnie z ValueTransformer’a.

class ImageTransformer: ValueTransformer

Dzięki temu dostaję szereg metod do nadpisania, które to będą w odpowiedni dla mnie sposób transformowały binarną wersję mojej zmiennej w graficzną.

Na początek metoda

transformedValueClass() -> AnyClass

która to zwraca typ w jaki nasza zmienna zostanie zamieniona. W naszym przykładzie będzie to NSData.

override class func transformedValueClass() -> AnyClass {
    return NSData.self
}

Następna rzecz – sama transformacja. Do tego nadpiszę metodę

transformedValue(_ value: Any?) -> Any?

w taki sposób, żeby z przekazanej przeze mnie zmiennej dowolnego typu – zrobić zadeklarowany wcześniej – NSData.

override func transformedValue(_ value: Any?) -> Any? {
    guard let image = value as? UIImage else { return nil }
    return image.pngData()
}

Ok, mamy to. Brakuje jeszcze tylko jednej rzeczy. Mianowicie teraz transformujemy jedynie UIImage na NSData. A co z odczytem? No właśnie – jeżeli zostawimy to w takiej postacie to będzie oznaczało tylko możliwość transformacji w jedną stronę. Takie zastosowanie też jest czasem potrzebne, jednak w naszej sytuacji chcemy uzyskać dostęp do naszego obrazka. Dodajmy zatem dwie metody, które możemy nadpisać aby uzyskać ten efekt:

override class func allowsReverseTransformation() -> Bool {
    return true
}
    
override func reverseTransformedValue(_ value: Any?) -> Any? {
    guard let data = value as? Data else { return nil }
    return UIImage(data: data)
}

Pierwsza umożliwia nam wykonywanie transformacji w drugą stronę, a druga mówi jak to zrobić. Pozostaje jedynie zmienić w modelu typ naszej zmiennej avatar na Transformable i dodać odpowiedni transformator w inspektorze:

Ustawianie transformera w inspektorze

Co ciekawe – jeżeli teraz wygenerujemy klasę dla naszego modelu, to nie wygeneruje się typ. Dlatego trzeba zadeklarować go ręcznie:

@NSManaged public var avatar: ?
@NSManaged public var avatar: UIImage?

I dopiero teraz mamy komplet ✅!

Custom objects

ValueTransformer może również służyć nam do przechowywania obiektów o naszym własnym typie. Jedyne zastrzeżenie – musi on dziedziczyć po NSObject – jak wszystko w Core Data. Zatem załóżmy, że chcemy przechowywać obiekt typu UserDetails, w którym będą takie zmienne jak kolor oczu, włosów oraz np. Ilość posiadanych samochodów (nie mam pojęcia skąd mi ten pomysł przyszedł do głowy 😅). Możemy w tym celu stworzyć nową encję o nazwie UserDetails i dodać relację do encji User, ale nie o to nam chodzi. Zarządzanie relacjami w Core Data jest pamięciożerne i wymaga od nas również trochę zachodu. Dla niektórych prostych danych (ale nie na tyle prostych by były po prostu liczbą) potrzebujemy czegoś prostszego. Np… Serializacji!

NOTE O serializacji obiektów za pomocą protokołu Codable oraz NSCoding pisałem już w tym artykule.

Zaczynam zatem od stworzenia klasy UserDetails, która będzie dziedziczyła z NSObject oraz implementowała protokół NSCoding. Następnie napiszę prostą serializację dla tej klasy oraz pusty konstruktor. Moja klasa wygląda zatem następująco:

class UserDetails: NSObject, NSCoding {
    var eyeColor: String?
    var hairColor: String?
    var carsNumber: Int?
    
    override init() {
        super.init()
    }
    
    func encode(with coder: NSCoder) {
        coder.encode(eyeColor, forKey: "eyeColor")
        coder.encode(hairColor, forKey: "hairColor")
        coder.encode(carsNumber, forKey: "carsNumber")
    }
    
    required init?(coder: NSCoder) {
        eyeColor = coder.decodeObject(forKey: "eyeColor") as? String
        hairColor = coder.decodeObject(forKey: "hairColor") as? String
        carsNumber = coder.decodeObject(forKey: "carsNumber") as? Int
    }
}

Teraz potrzebuję ValueTransformer dla mojego typu UserDetails. Mogę użyć metod klas NSKeyArchiver oraz NSKeyUnarchiver, ale wydaje mi się że można zrobić to lepiej. Np. Poprzez… Codable!

Implementując protokół Codable do obiektu UserDefaults mogę skorzystać z serializacji przy pomocy metod już zaimplementowanych oraz z PropertyListEncoder i PropertyListDecoder. Mój UserDetailsTransformer wygląda teraz tak:

class UserDetailsTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass {
        return NSDate.self
    }

    override func transformedValue(_ value: Any?) -> Any? {
        guard let userDetails = value as? UserDetails else { return nil }
        do {
            return try PropertyListEncoder().encode(userDetails)
        } catch {
            return nil
        }
    }

    override class func allowsReverseTransformation() -> Bool {
        return true
    }

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? Data else { return nil }
        do {
            return try PropertyListDecoder().decode(UserDetails.self, from: data)
        } catch {
            return nil
        }
    }

}

Bardzo fajnie! Lecimy zatem do modelowania encji w Core Data. Mamy naszego User’a do którego dodaję zmienną userDetails typu Transformable i dodaję UserDetailsTransformer w miejsce ValueTransformera:

Dodanie klasy UserDetailsTransformer

Eksportuję sobie klasę i podstawiam w miejsce typu dla zmiennej userDetails ➡️ UserDetails.

extension User {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<User> {
        return NSFetchRequest<User>(entityName: "User")
    }

    @NSManaged public var avatar: UIImage?
    @NSManaged public var name: String?
    @NSManaged var userDetails: UserDetails?

}

E voila! Teraz jeszcze tylko przetestujmy czy to nam zadziała. A jak testować, to tylko jednostkowo (bo przecież nie print(result) 😅):

import XCTest
@testable import TransformerApp

class TransformerAppTests: XCTestCase {
    override func setUp() {
        archive()
    }
    
    func testUserDetails() {
        guard let users = CoreDataStack.shared.fetchObjects(of: "User") as? [User] else {
            XCTFail("Can't fetch any user from Core Data")
            return
        }
        guard users.count > 0 else {
            XCTFail("No archived users")
            return
        }
        users.forEach {
            XCTAssertEqual($0.name, "Jakub")
            XCTAssertTrue($0.userDetails != nil)
            XCTAssertEqual($0.userDetails?.eyeColor, "Zielone")
            XCTAssertEqual($0.userDetails?.hairColor, "Czarne")
            XCTAssertEqual($0.userDetails?.carsNumber, 1)
        }
        // Clean things up
        flush()
        XCTAssertTrue(CoreDataStack.shared.fetchObjects(of: "User").isEmpty)
    }
    
    func archive() {
        let newUser = User(context: CoreDataStack.shared.managedContext)
        newUser.name = "Jakub"
        let userDetails = UserDetails()
        userDetails.eyeColor = "Zielone"
        userDetails.hairColor = "Czarne"
        userDetails.carsNumber = 1
        newUser.userDetails = userDetails
        CoreDataStack.shared.saveContext()
    }
    
    func flush() {
        CoreDataStack.shared.batchDelete(entity: "User")
    }
}

Wszystko na zielono ✅. Można się rozejść 👍.

Konkluzje

Oczywiście ValueTransformer to nie jedyny sposób na przechowywanie danych różnych typów w Core Data. Uważam też że ten artykuł nie wyczerpuje jeszcze całego tematu, dlatego jeśli temat był dla Ciebie ciekawy to zerknij poniżej na dodatkowe linki, które dla Ciebie znalazłem w czeluściach internetu.

Mam nadzieję że ten wpis przekona Cię (jeśli nie byłeś wcześniej) ze Core Data daje naprawdę dużo możliwości i jeśli performance nie jest dla Ciebie mega ważny i nie musisz martwić się o każdą milisekundę na procesorze, to śmiało możesz stosować ją w swoich projektach 😁.

Przydatne linki

Core Data Performance – dokumentacja Apple

Runtastic case

Hacking with Swift – zwiększenie wydajności Core Data przez dodanie NSFetchedResultsController

Core Data vs Realm in benchmarks

ValueTransformer w CoreData

Komentarze 3 thoughts on “ValueTransformer w CoreData

  • Avatar
    Luty 16, 2020 z 12:53
    Permalink

    Cześć,
    świetny artykuł :). Co sądzisz o Realm? Poczytałem podlinkowany przez Ciebie artykuł: Core Data vs Realm in benchmarks i ciężko mi wskazać zwycięzcę :).

    Powtórz
    • Jakub Kornatowski
      Luty 16, 2020 z 18:58
      Permalink

      Hej, super że się podobał 🙂

      Przede wszystkim to trzeba zaznaczyć, że w tej rywalizacji nie ma zwycięzcy. Każdy będzie miał swojego faworyta, jak i “tego gorszego”. Przede wszystkim realm to zewnętrzna zależność, jaką trzeba do projektu dołączyć. Tak samo jak Core Data integruje kod Objective-C. Core Data jest z nami od lat i poprawnie używana posiada wiele ciekawostek, jak np. NSFetchedResultsController – którego ekwiwalentu nie ma w Realm. No i Realm ma mniej przejrzyściej napisaną warstwę persystgncji, trudniej zachować tutaj czystość kodu (ale da się 🙂 ).

      Nie śmiałbym powiedzieć, że któreś jest lepsza, to zależy od wielu czynników i warto spróbować obu rozwiązań w jakiś pobocznych projektach i wyrobić sobie zdanie samemu. Ja lubię to co jest native, bo ufam dostawcy, że nawet jeśli postanowi to zaorać, to da mi coś w zamian. W przypadku community-driven rozwiązań tej pewności nigdy nie masz 😁.

      Pozdrawiam i dzięki za komentarz!

      Powtórz
      • Avatar
        Luty 19, 2020 z 08:53
        Permalink

        Super ! Rozumiem, dziękuję za odpowiedź.

        Powtórz

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *