CoreData with background task

前言

雖然在 iOS 上開發了幾年的時間,但一直到最近才開始使用 CoreData;之前在第一份工作的專案之中是使用 FMDB 來處理資料存取,而後續則是用了 Realm

選擇的原因分別是當時 FMDB 的速度較其餘兩者快速,而後來看上 Realm 的跨平台特色,不過近期開發的感想是能以原生為主的話,就儘量降低對於第三方套件的依賴性。

這篇文章會記錄些什麼

其實這篇文章並不會從頭到尾寫下教學,而把重點放在一些我踩到的雷上,像是⋯⋯

記得要附上 sqlite 的路徑

原先我的 persistentContainer 的產生方式如下

lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "OfflineWallet")
    let description = NSPersistentStoreDescription()
    description.shouldInferMappingModelAutomatically = true
    description.shouldMigrateStoreAutomatically = true
    container.persistentStoreDescriptions = [description]
    container.loadPersistentStores { _, error in
        if let error = error {
            fatalError("Unresolved error \(error), \(String(describing: error._userInfo))")
        }
    }
    return container
}()

在模擬器上存取了幾次,每次都有 save 且第二次進入畫面的時候,都可以 fetch 得到資料,但是只要重開 App 就會從頭來過⋯⋯

也就是說其實都只是像是存在 NSManagedObjectContext 上,而並沒有實際地轉成 sqlite

補上指定的 URL 即可解決。

...
let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
let sqliteURL = documentsDirectoryURL?.appendingPathComponent("OfflineWallet.sqlite")
description.url = sqliteURL
...

performBackgroundTask v.s newBackgroundContext

要實作 backgroundTask 有兩種作法

NSPersistentContainer.performBackgroundTask

persistentContainer.performBackgroundTask { context in
    //do something in background thread
}

NSPersistentContainer 會建立一個 context 在這個 closure 裡頭使用,而重點是當這條 thread 結束之後,這個 context 所管理的物件(NSManagedObjectModel)的所有變數會釋放掉,也就是都會變成 nil

適合用於取得資料並轉型成其他 class / structure 的時候使用。

newBackgroundContext

而如果你必須在後續的程式之中繼續使用 context 所產生的 NSManagedObjectModel 時,你就得要保存其 context;像是建立一個 backgroundContext 並存下來使用:

lazy var backgroundContext = persistentContainer.newBackgroundContext()

//you should use this context to do something what you want
backgroundContext.perform {
//do something like before
}

此時由於這個 context 並沒有被釋放掉,所以其 NSManagedObjectModel 的所有變數便也會持有著;而依然是在其他 thread 上進行,並不會佔據 main thread。

待續

分頁讀取

筆記一下 NSFetchRequest 有提供 fetchLimit 以及 fetchOffset 可以用來做分頁讀取的功能。

SwiftLint

SwiftLint

去年(2018)年末的時候,在 Twitter 上看到一些朋友們在討論著 SwiftLint 的使用,於是便也嘗試在目前公司的專案中導入,來解決 Coding Style 的問題。

Coding Style 的問題

剛進入到這家公司時,最痛苦的事情莫過於毫無章法的 Coding Style,這部分就不一一細數了,詳情可以看前陣子我的 Twitter 動態

專案裡頭每個人寫的格式不同,會造成其他人在閱讀專案時,大幅增加理解彼此想法的成本

簡單來說就是浪費一堆時間在猜你在寫什麼

為了讓專案裡頭的大家有差不多的 Coding Style,可以選擇使用 SwiftLint 來處理這個情況。

實際嘗試

首先先以 SwiftLint 的官方教學來安裝和建構環境,並利用 EthanSwiftLint rules 當作基底來調整,先以別人的規範來看看差異性。

一跑下去便是直接噴了 3000+ issues⋯⋯

可以透過 Rules.md 來逐條看看定義並透過 example 來看怎麼算是 Non TrggeringTriggering;再來一條一條調整改進。

一些心得

目前公司的專案從 3000+ ➡️ 17 warings,而這 17 條是我還沒 refactor 到的 features,所以就還沒那麼急著去調整。

private_outlet 和 private_action

而其中一條規則 private_outlet 給我的感觸挺深刻的,由於我大多數的工作經歷是獨立開發居多,而每個 IBOutlet 都必須為 private 是第一份工作時所踩到的坑;當時公司共有兩位 iOS 工程師,彼此皆為 Junior 的程度,故沒什麼規範和概念,於是便會出現一些神秘的情境⋯⋯

像是不知道為什麼你負責的 UIViewController 刻出來的畫面就是和你想的不太一樣,才發現另一個地方(別人寫的 code)在直接修改畫面的 Layout / value⋯⋯

後來就體會到物件的每個變數和 functions 的 access 問題,而這條規則便是解釋著 IBOutlet 不應該可以直接從其他地方呼叫、修改,像是被這麼做:

fooViewController.fooLabel.text = "Test"

從那時候開始養成的習慣到現在,當發覺其實有其他人也是這麼做,並將它視為一條 rule 的時候真的覺得有點小感動!

整體來說

你可以透過這個 SwiftLint 來反覆思考一些寫程式上的問題,像是 function 的長度、class 的長度以及 Swift 檔案的長度等;如何切割每個物件和 function 等,都是相當值得去思索的習慣問題。

不過也不需要逐條都導入到專案之中,建議是花一些時間找到你最認同的那幾條 rules 來遵循即可!

Create CocoaPods by yourself

這篇文章會介紹建立自己的 CocoaPods 流程,而我當初是看了 David 的教學文所跑的流程。
首先,先建立一個新的 Pod

pod lib create YourPodName

接著依照自己的 Pod 內容回答問題,便會生成一個新的專案出來。
再來將 Code.Swift 丟到 Pods/Development Pods/YourPodName,也就是它預設 ReplaceMe.Swift 的那個地方。
最後 README.md 和 YourPodName.podspec 寫一寫就完成了 Pod 的準備。
而預設的 REAMME.md 裡頭,有一個 CI Status,你可以移除或者到 Travis.CI 建構;
從 Travis.CI 那邊可以得知如何建構一個 .travis.yml。
都準備好之後,在 GitHub 上開一個 repository 來放置,記得要放上 tag 標記目前的版本。

pod spec lint YourPodName.podspec

最後就送出去就好了!

pod trunk push YourPodName.podspec

如果你沒有註冊過的話,得先註冊一下:

pod trunk register email@domain.com 'Your Name'

而如果你有在 .podspec 裡頭填寫你的 Twitter 的話,就會收到 CocoaPods 貼的文!
如果你搜尋不到你的 Pod 的話,可以清除目前的 cache 就可以順利找到了!

rm -rf ~/Library/Caches/CocoaPods

UUID with version 3, 5 and name spaces

在 Swift 裏頭,預設的 UUID 只能從 UUID() 來產生,或者是從另一個 UUID 來產生,
這邊來記錄一下如何從 String 來產生 UUID。
首先,先在 Bridge-Header.h 裡頭加入

#import <CommonCrypto/CommonCrypto.h>

再來寫個 UUID 的 extension:

就可以使用新的 init method 來產生新的 UUID:

UUID(version: UUIDVersion, name: String, nameSpace: UUIDv5NameSpace)

Socket.IO

這篇文章會介紹有關於 Socket.IO 在 server 端以及 iOS 端的一些實作分享。

Server

首先我們可以先從 Socket.IO 的 Get started 開始,它是以 Node.JS 所編寫的,所以先在資料夾位置執行

npm init

邊可以取得基本的一些檔案和 package.json。
接著安裝 Socket.IO 所需要的 express

npm install --save express@4.15.2

然後新建一個 index.js 的檔案,貼上

var app = require('express')();
var http = require('http').Server(app);
app.get('/', function(req, res){
    res.send('<h1>Hello world</h1>');
});
http.listen(3000, function(){
    console.log('listening on *:3000');
});

這樣待會執行 node index.js 的時候便可以從 http://localhost:3000 來連上這個 server。
確認 server 目前是可以連上後,便開始安裝 Socket.IO 到其中

npm install --save socket.io

並在 index.js 裏頭加上 Socket.IO 的 code:

var io = require('socket.io')(http);
io.on('connection', function(socket){
    console.log('a user connected');
});

這樣便完成監聽 client 連接上 server 的事件了。
'connection' 是 socket.io 所定義的 event,而這個 event 會取得 socket 回來,所以以 function(socket) 的方式去接收並處理後續。
不過這裡的 io 可以想成是一個 server,所以每收到一個 client 的 connection 事件,都會執行 function(socket);而我們若要監聽個別連線的事件,則是使用 socket 來處理:

socket.on('disconnect', function(){
    console.log('user disconnected');
});

也就是說,像是登入、傳送訊息等,和個別使用者相關的動作,我們都是以 socket 來處理。
而除了監聽(on)外,發送(emit)的動作也是如此,像是我想推一段文字給特定的使用者:

socket.emit('new message', '嗨!');

在 client 以及 server 端先彼此定義好 event 名稱,這樣就可以知道要監聽的事件為哪些。
什麼時候該用 io 發送,什麼時候該用 socket 呢?
舉個例子,我們建立一個 Socket.IO 的大聊天室,任何人只要連上這個網址,便等同於加入這個聊天室。

  • 使用者上、下線會顯示提醒
  • 說髒話的人會個別收到「此訊息無法傳送」的訊息
io.on('connection', function(socket) {
    io.emit('new user', '有人加入了這個聊天室!');
    socket.on('add message', function(from, message) {
        if (message.indexOf('馬的') > -1) {
            socket.emit('new message', '此訊息無法傳送');
        } else {
            io.emit('new message', from + ' 說:' + message);
        }
    })
    socket.on('disconnect', function() {
        io.emit('user leave', '有人離開了這個聊天室!');
    });
});

上面便可以得知哪種時候應該要大家都可以接收到、而哪種是只會有個別的使用者接收到。
再來介紹一下 ack,在發送一個 event 時,可以在後面補上一個 ack,而當對方收到的時候,可以透過 ack 來傳遞 data,而非再發送一個新的 event。
有點像是 HTTP request 的概念,發送一個 request(emit),接著對方會回傳 response(ack),不過這並不一定會有,也就是說你發送(emit)了一個 event 過去,雖然有夾帶著 ack,但是對方若沒接收那個 ack 的話也是沒用。
這邊是一個例子,我們從 server 給沒有收過這則廣告的使用者傳送一則過去,並希望使用者真的有收到,若沒收到下次就再傳一次:

// 判斷使用者是否有收到過廣告,若沒有的話執行
socket.emit('new advertise', '恭喜您獲得 $1,000 元折扣!', function(userID) {
    console.log('使用者(' + userID + ')收到廣告了!');
    //去資料庫更新,下次不用再推給這個 userID
)};

其中,function(userID) 便是一個 ack 或者可以說是 callback。
相對的,收到訊息的時候,server 也可以回傳 ack 回去給 client 告知:

socket.on('add message', function(text, ack) {
    console.log('收到: ' + text);
    ack('伺服器收到你的訊息了!請放心!);
});

如此一來,便會在收到訊息之後,以 ack 的方式回傳訊息回去。
而 ack 和 emit 所發送出去有什麼不同?
在 Socket.IO 的 protocol 裏頭有定義:

  • Packet#CONNECT ( 0 )
  • Packet#DISCONNECT ( 1 )
  • Packet#EVENT ( 2)
  • Packet#ACK ( 3 )
  • Packet#ERROR ( 4 )
  • Packet#BINARY_EVENT ( 5 )
  • Packet#BINARY_ACK ( 6 )

所以其實雖然動作類似,但 Socket.IO 可以辨別其中的差異,進而可以在 Socket 之中達到 Request、Response 的概念。

Client

Client 的部分,可以使用 Socket.IO 所提供的 Swift framework,它是基於 Starscream 所開發出來的,如同上述說的有使用到 WebSocket 來連接。
以 Swift 來說:

let manager = SocketManager(socketURL: URL(string: "http://localhost:3000")!, config: [configs])
let socket = manager.defaultSocket

這邊介紹幾個我所使用到的 configs

  • .log(true):開啟 LOG 的功能。
  • .forceWebsockets(true):若沒有使用這個的話,會以 HTTP polling 的方式連接,從 header 來看的話,就會顯示 connection: keep-alive;而使用了 .forceWebsockets(true) 的話,則會使用 WebSocket 來連線,則會顯示 connection: upgrade。
  • .reconnectAttempts(int):重新嘗試連線 n 次,超過就放棄。
  • .connectParams([String: Any]):這邊可以放 token 來做 Authentication。

為什麼不在 header 裏頭加上 Authentication 的欄位?
這邊 Socket.IO 有做解釋,為什麼不建議在 extraHeaders 加東西。
而 on / emit / emitWithAck 這幾個的用法就和 server 的概念一樣,這邊就不再多做解釋,
之後實作有遇到什麼事情再來補充(或是新文章)!

StatusBarStyle

最近改寫了 StatusBarStyle 的控制方式,從 iOS 9 之前是使用

UIApplication.shared.statusBarStyle = .default

並在 info.plist 的 View controller-based status bar appearance 欄位設為 NO。
而後來則改為覆寫 preferredStatusBarStyle 的方式,就不用在 viewWillAppear / viewWillDisappear 的時候手動控制 StatusBarStyle。
首先先確認 info.plist 之中 View controller-based status bar appearance 是為 YSE,
代表我們是透過 View controller-based 的方式來更改 status bar style。

override var preferredStatusBarStyle: UIStatusBarStyle {
    return .default
}

而配合 UINavigationController 的使用,可以以 Subclass 或是 Extension 的方式來實作,
這就得看專案需求了。

Subclass

在 BaseNavigationController 裏頭,利用 childForStatusBarStyle 回傳要呼叫哪個 UIViewController 的 preferredStatusBarStyle

override var childForStatusBarStyle: UIViewController? {
    return topViewController
}

這樣每當 UINavigationController 畫面在切換時,便會將 topViewController 叫出來問問它 preferredStatusBarStyle 要什麼樣式。

Extension

我們透過 extension UINavigationController 的方式也可以達到一樣的效果

extension UINavigationController {
    override open var childForStatusBarStyle: UIViewController? {
        return topViewController
    }
}

如此就不用為了 childForStatusBarStyle 來多寫一個 subclass。

Xcode beta with CocoaPods

Xcode 10 beta 的 Swift 版本為 4.2,而若你目前所使用的 Pods 多數為 Swift 4 的話,該怎麼辦呢?
你可以在 Podfile 裏頭加上全域的參數來規範所有 Pods 的 Swift version:

post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
            config.build_settings['SWIFT_VERSION'] = '4'
        end
    end
end

如此一番便可以輕鬆地在 Pods 還沒全面支援 Swift 4.2 時就可以使用 Xcode 10 Beta 開發👏

CompactMap vs flatMap

直接從 code 來看兩者之間和 map 的差異
CompactMap

let scores = ["1", "2", "3", "four", "5"]
let mapped: [Int?] = scores.map { str in Int(str) }
// [1, 2, 3, nil, 5] 
let compactMapped: [Int] = scores.compactMap { str in Int(str) }
// [1, 2, 3, 5]

flatMap

let users = [User(name: "Archie", scores: [1, 2, 4]), User(name: "ArchieChang", scores: [3,2,5])]
let mapped = users.map { $0.scores }
// [[1, 2, 4], [3, 2, 5]]
let flatMapped = users.flatMap { $0.scores }
// [1, 2, 4, 3, 2, 5]

在使用情境上,CompactMap 可以將 nil 給過濾掉,使得回傳的陣列為 non optional 的型態;
原先還在使用 map + filter 來處理 nil 的部分,可以直接使用 CompactMap 處理,減少多一次的陣列迴圈。
而 flatMap 則是在將所有陣列的內容整合進同一個陣列,如我們需要計算全體使用者的總得分時,便可以先將個別使用者的分數集結成一個陣列來處理。

UserDefaults with Structure

有時候我們會將一些用戶資訊存在 UserDefaults 裡頭,是個方便且直覺的存取方式。
而 UserDefaults 並非是所有型別都可以接受,如你自己建構的 struct 或 class,
就需要先轉成 Data 的格式來存取。
這邊就來介紹如何存取 User 這個 struct:

struct User {
    var ID: String
    var name: String
    var email: String?
}

這是一個簡單的 User structure,若要轉成 Data 的話,得先將 User 宣告成 Codable,
這樣就可以透過 PropertyListEncoder 和 PropertyListDecoder 來處理 User 和 Data 之間的 encode decode。
而最近喜歡使用 extension 的方式來處理 UserDefaults:

extension UserDefaults {
    var user: User? {
        get {
            guard let data = data(forKey: #function) else { return nil }
            return try? PropertyListDecoder().decode(Profile.self, from: data)
        }
        set {
            if let profile = newValue {
                set(try? PropertyListEncoder().encode(profile), forKey: #function)
            } else {
                set(nil, forKey: #function)
            }
        }
    }
}

我們利用 #function 的特性,將 function name (user) 直接作為 UserDefaults 的 key,
再加上一些判斷是否為 nil 的處理,便可以輕鬆底使用 UserDefaults 存取我們所定義的 structure。
存取 UserDefaults 裡頭的 User 就可以變成很單純的 get 和 set:

// get user from UserDefaults
let user = UserDefaults.standard.user
// set user
UserDefaults.standard.user = user

Facebook 隱私權條款問題

由於之前上架的 App 有使用到 Facebook 的登入功能,
於是這幾天一直收到這類的信件:
Demo
但是由於本身對這方面可以說是完全沒有涉略,
所以便找找有沒有什麼辦法解決。

Free Privacy Policy

於是找到了這個,回答五種題型便可以得到它們產生的條款,
再找個地方貼上補連結到 Facebook 即可!