Contents
Chào bạn đến với series Lập trình iOS cho mọi người. Nếu bạn là người theo dõi series này từ ngày đầu, thì các bài viết trước chỉ trong phạm vị của ứng dụng. Bài viết này sẽ vượt ra ngoài ứng dụng của bạn. Và chủ đề hôm nay sẽ là về Connect Networking trong iOS Project.
Để chuẩn bị cho bài này thì bạn cần phải nắm các kiến thức sau:
Để sử tiết kiệm thời gian thì source code iOS Project, thì sẽ sử dụng lại của bài MVVM. Nếu bạn chưa nắm được kiến thức thì hãy quay lại các link trên để bổ túc. Còn nếu bạn đã sẵn sàng thì …
Bắt đầu thôi!
Chuẩn bị
- MacOS 10.14.4
- Xcode 11.0
- Swift 5.1
1. Bổ túc văn hoá
Bạn là 1 lập trình viên chân chính, thì các kiến thức cơ bản về networking thì cũng phải nắm được. Sau đây, mình trình bày lại các khái niệm sẽ được dùng nhiều trong bài. Còn bạn muốn tìm hiểu kĩ về nó, thì có thể vài đường Google là ra. EZ Game!
1.1. Webservice & API
Web service là một hệ thống phần mềm được thiết kế để hỗ trợ khả năng tương tác giữa các ứng dụng trên các máy tính khác nhau thông qua mạng Internet, giao diện chung và sự gắn kết của nó được mô tả bằng XML. (theo W3C)
API (Application Programming Interface – Giao diện lập trình ứng dụng).
- API là các hàm , phương thức để cho các ứng dụng bên ngoài có thể gọi , tương tác để trao đổi thông tin , tính toán.
- Việc trao đổi này giúp các nhà lập trình tạo ra các service hỗ trợ những lập trình viên khác có thể tương tác với ứng dụng của chính mình
- Hiện nay trên web các dịch vụ của google , facebook cung cấp rất nhiều api để lập trình viên có thể xây dựng tương tác giữa website của họ với google ,facebook
Web API
- Khi đưa các API từ việc tương tác giữa các máy tình hay các phần mềm với nhau thành mô hình client / server thì mô hình Web API dần được hình thành.
- Web API dùng phương thức trao đổi dữ liệu là HTTP , kiểu dữ liệu trao đổi là JSON , một chuẩn dữ liệu hướng đối tượng được dùng khá nhiều trong việc lưu chuyển thông tin trên Internet .
- Tương tác với khá nhiều các nền tảng: mobile, web, pc, các hệ điều hành, các thiết bị ngoại vi…
- RESTful đang là chuẩn phổ biến hiện nay.
1.2. Endpoint
Endpoint thực chất chính là URL: https://abc.com/foo/bar
và lúc này ta gọi /foo/bar là Endpoint vì URL đằng trước thì giống nhau trong hầu hết các trường hợp.
1.3. URL
Uniform Resource Locator, dịch ra tiếng Việt là “Định vị Tài nguyên thống nhất” hay “Định vị Tài nguyên đồng dạng”. CLGT!
Đơn giản hơn “địa chỉ web”
Thành phần bao gồm:
- Protocol: http, https, mailto
- Domain
- Port: 8088 , …
- Path
- Value
Ví dụ: có 1 URL sau: https://www.google.com/maps/place?type=abc
. Trong đó:
-
- Protocol : https
- Domain: www.google.com
- Path: /map/place
- Value: ?type=abc
1.4. Query String
- Dùng để đọc dữ liệu do trang khác gửi tới thông qua phương thức GET
- Thường là gửi dữ liệu bằng cách gắn vào ngay sau liên kết – URL.
Ví dụ: http://www.abc.com/TinhTuoi.php?NamSinh=1980
1.5. Method
- Là phương thức mà HTTP Request này sử dụng.
- Bao gồm:
- GET
- POST
- HEAD
- PUT
- DELETE
- OPTION
- CONNECT
1.6. Request
Chứa các thông tin mà từ clien gởi lên server thông qua một connect được thiết lập.
Bao gồm:
- Header : nơi chứa thông tin cấu hình và tính chất của Request được gởi đi.
- Body: nơi chưa dữ liệu cho request
1.7. Response
Là dữ liệu mà phía server gởi về client sau khi thực hiện 1 request. Cũng bao hồm Header & Body như request.
Tuỳ thuộc vào loại dữ liệu mà bạn có sử dụng thì chọn cách format chúng theo đúng như vậy. Công việc này người ta gọi là parse data
.
1.8. JSON
- JavaScript Object Notation (thường được viết tắt là JSON) là một kiểu dữ liệu mở trong JavaScript.
- Kiểu dữ liệu này bao gồm chủ yếu là text, có thể đọc được theo dạng cặp “thuộc tính – giá trị”.
- Về cấu trúc, nó mô tả một vật thể bằng cách bọc những vật thể con trong vật thể lớn hơn trong dấu ngoặc nhọn ({ }).
- JSON là một kiểu dữ liệu trung gian, chủ yếu được dùng để vận chuyển thông tin giữa các thành phần của một chương trình
Ví dụ:
{ "name":"Product", "properties": { "id": { "type":"number", "description":"Product identifier", "required":true }, "name": { "description":"Name of the product", "type":"string", "required":true }, "price": { "type":"number", "minimum":0, "required":true }, "tags": { "type":"array", "items": { "type":"string" } } } }
Trong iOS thì kiểu dữ liệu mô tả cho JSON là Dictionary với key là
String
và value làAny
.
2. Mô hình
2.1. Client Server
Đây là mô hình siêu kinh điển trong lập trình.
- Client mang nghĩa rộng. Có thể là thiết bị, người dùng, phần mềm …
- Client luôn luôn là phía gởi request
- Server là bên tiếp nhận request, xử lý thông tin và trả về response cho client
2.2. Tương tác trong ứng dụng
Sơ đồ trên là mô tả lại sự tương tác cơ bản trong ứng dụng. Chúng ta cần chú ý vài điểm như sau:
- Để thực hiện bất cứ tương tác nào ra bên ngoài thì bạn cần nhớ điều đầu tiên là sử dụng 1 thread mới.
- Tiếp theo là việc chờ đợi phản hồi từ Server.
- Phân tích dữ liệu nhận được từ Server.
- Đinh dạng lại dữ liệu cho đúng mục đích sử dụng.
- Quay về Main Thread để cập nhật lại giao diện.
Về lý thuyết như vậy là ổn rồi. Tiến vào code thôi!
3. Hoạt động
Trước tiên thì bạn cần tìm 1 URL nào đó, có thể bất cứ cái nào cũng được. Hoặc sử dụng chính hàng của Apple như sau:
- Rss Feed: http://rss.itunes.apple.com/en-us
- Ví dụ: Hot Music từ iTunes : https://rss.itunes.apple.com/api/v1/us/itunes-music/hot-tracks/all/10/explicit.json
Về iOS Project thì:
- Thêm 1 UITableView cho
HomeViewController
- Custom cell cho Tableview trên
- Add thêm 1 UIBarButtonItem cho NavigationBar. Khi người dùng nhấn vào đó thì sẽ gọi function lấy dữ liệu từ URL trên.
File HomeViewController
sẽ trông như thế này:
import UIKit class HomeViewController: BaseViewController { @IBOutlet weak var tableview: UITableView! var viewmodel = HomeViewModel() override func viewDidLoad() { super.viewDidLoad() } // MARK: - config override func setupUI() { super.setupUI() //title self.title = "Home" //tableview tableview.delegate = self tableview.dataSource = self let nib = UINib(nibName: "HomeCell", bundle: .main) tableview.register(nib, forCellReuseIdentifier: "cell") //navi let resetTabbarItem = UIBarButtonItem(image: UIImage(named: "ic-navi-refresh"), style: .plain, target: self, action: #selector(loadAPI)) self.navigationItem.rightBarButtonItem = resetTabbarItem } override func setupData() { } func updateUI() { tableview.reloadData() } //MARK: - API @objc func loadAPI() { print("LOAD API") } } //MARK: - Tableview Delegate & Datasource extension HomeViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewmodel.names.count } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 200 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! HomeCell cell.titleLabel.text = viewmodel.names[indexPath.row] return cell } }
Tiến sang ViewModel của nó là HomeViewModel
. Chúng ta tiến hành cài đặt thêm một số thứ như sau:
- Tạo ra 1 kiểu dữ liệu closure mới tên là
Completion
. Dùng làmcall back
cho các function của ViewModel.Bool
: thông báo việc load API có thành công hay khôngString
: chứa nội dụng cụ thể bị lỗi gì trong quá trình tương tác với API
- 1 array String tên là
names
, để lưu các giá trị lấy được từ response.
import Foundation typealias Completion = (Bool, String) -> Void class HomeViewModel { var names: [String] = [] func loadAPI(completion: @escaping Completion) { //chưa code gì hết nha. } }
3.1. Create Request
Bắt đầu bằng việc gọi ViewModel thực thi yêu cầu từ Controller. Tại function loadAPI
. Chúng ta sẽ gọi viewmodel tại đây:
@objc func loadAPI() { print("LOAD API") viewmodel.loadAPI { (done, msg) in if done { self.updateUI() } else { print("API ERROR: \(msg)") } } }
Nếu như việc tương tác thành công, thì sẽ gọi hàm updateUI
để cập nhật lại giao diện. Nếu thất bại, thì in lỗi ra. Trong thực tế, bạn phải hiển thị ra alert
, nhằm thông báo cho người dùng biết đang có lỗi. Còn bài này thì tạm thời mình bỏ qua nha!
Tại function loadAPI
của HomeViewModel
. Ta bắt đầu việc tạo request
.
String URL ==> URL ==> Request
- Bước 1: Biến đổi từ string của URL thành đối tượng URL
let urlString = "https://rss.itunes.apple.com/api/v1/us/itunes-music/hot-tracks/all/100/explicit.json" let url = URL(string: urlString)
- Bước 2: Tạo đối tượng Request từ đối tượng URL
let request = URLRequest(url: url!)
- Bước 3: Nếu có thêm tham số hay cấu hình gì cho request thì thêm vào. Còn không thì có thể bỏ qua.
request.httpMethod = "GET"
Với một số API đơn giản, bạn có thể lượt bỏ đi request. Dùng trực tiếp đối tượng URL cho phần sau vẫn không sao.
3.2. Connection
Đây là phần chính. Với iOS thì bạn có rất nhiều cách, cũng như rất nhiều thư viện phục vụ việc tương tác với API. Trong bài này chúng ta sử dụng URLSession
.
- Đầu tiên tạo cấu hình cho Session
let config = URLSessionConfiguration.ephemeral config.waitsForConnectivity = true
- Tạo đối tượng
URLSession
từ cấu hình trên
let session = URLSession(configuration: config)
- Tạo 1
URLSessionDataTask
từ session và request. Ở đây nó sẽ chưa thực thi, chỉ mang tính chất setup chuẩn bị cho việc connect.
let task = session.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if let error = error { print("API - THẤT BẠI") } else { print("API - THÀNH CÔNG") } } }
Việc tương tác này là bất đồng bộ.
Nên khi setup dataTast
, ta thấy có 1 closure
nhận về. Trong đó:
-
data
là dữ liệu ở dạng nhị phân. Tức mãng bytes hay kiểu dữ liệu của nó iOS làData
response
chứa thông tin của response trả về. Bạn có thể lấy đượcstatus_code
, để biết là thành công hay không.error
là lỗi. Nếu có lỗi phát sinh thì error sẽ khácnil
.
Vì công việc thực thi là bất đồng bộ, khi bạn muốn cập nhật lại giao diện thì phải về lại Main Thread. Nên phải sử dụng tới DispatchQueue
. Còn nếu bạn quên mất kiến thức về DispatchQueue hay GCD thì tham khảo các bài viết về nó trong Fx Studio.
- Cuối cùng là việc thực thi
connect
. Sử dụng hàmresume
của Task.
task.resume()
Tới đây, thì bạn nên build và test project của bạn có hoạt động đúng như mong muốn chưa. Nếu oke rồi thì chúng ta sẽ tiến qua phần xử lý response.
3.3. Receive Response
Dữ liệu nhân được từ việc phản hồi của Server là các mã nhị phân. Hay kiểu dữ liệu trong iOS đó là
Data
.
Công việc chính trong phần này chính là biến đổi dữ liệu. Vì từ Data
bạn có thể biến đổi thành
- Text
- HTML
- XML
- JSON
- Image
- Video
- Audio
- …
Trong bài chúng ta sẽ biến đổi nó về JSON. Để tiện lợi thì chúng ta lại viết thêm một extension
cho lớp Data
. Tạo 1 file swift với tên là Data.Ext.swift
. Trong đó:
- Định nghĩa kiểu
JSON
, bằng cách định danh lạiDictionary
- Thêm 1 function cho lớp
Data
import Foundation typealias JSON = [String: Any] extension Data { func toJSON() -> JSON { var json: [String: Any] = [:] do { if let jsonObj = try JSONSerialization.jsonObject(with: self, options: .mutableContainers) as? JSON { json = jsonObj } } catch { print("JSON casting error") } return json } }
Quay về với file HomeViewModel
. Tiến hành việc phân tích closure nhận được từ Server
let task = session.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if let error = error { //call back completion(false, error.localizedDescription) } else { if let data = data { let json = data.toJSON() //call back completion(true, "") } else { //call back completion(false, "Data format is error.") } } } }
Ta thay các print bằng việc gọi closure của hàm loadAPI
. Nếu
- Thành công thì Bool là
true
và String làrỗng
- Thất bại thì Bool là
false
và String làchuỗi string
Khi nhận được response thì việc đầu tiên là kiểm tra xem error != nil
hay không. Để xác định việc tương tác thành công hay là lỗi.
Error là 1 optional.
Tiếp theo, nếu vào trường hợp thành công thì bạn cần phải kiểm tra tiếp là data
của bạn có tồn tại hay không. Vì 2 việc này cũng không liên quan với nhau lắm.
Đôi khi thành công trong việc
connect
và nhận đượcresponse
. Nhưng dữ liệu bị lỗi thì vẫn xãy ra. Và data cũng là 1 optional.
Cuối cùng là call back
cho ViewController biết tình hình như thế nào. Thông qua việc gọi các completion
.
3.4. Parse data
Công việc này chính là phân tích JSON để lấy được giá trị mong muốn.
Kiểm tra JSON trên trình duyệt web và ngồi suy luận một tí.
feed
chứa toàn bộ thông tin. Nó là 1 JSONresults
, nó ở trong feed. Và nó là một Array JSON. Nó chứa các phần tử mình cần.- Trong mỗi phần tử, ta thấy
name
. Nó chính là cái tên mình cần sử dụng để hiển thị lên TableView.
Tiến hành chuyển đổi qua code Swift. Ta được:
let json = data.toJSON() let feed = json["feed"] as! JSON let results = feed["results"] as! [JSON]
results
là một mãng. Nên tiếp tục for
để duyệt từng phần tử trong đó. Và lấy giá trị của name
ra. Thêm nó vào array names
của ViewModel. Nhằm lưu trữ dữ liệu cho View.
for item in results { let name = item["name"] as! String self.names.append(name) }
Xem lại toàn bộ code của function loadAPI
tại HomeViewModel
.
func loadAPI(completion: @escaping Completion) { //create request let urlString = "https://rss.itunes.apple.com/api/v1/us/itunes-music/hot-tracks/all/100/explicit.json" let url = URL(string: urlString) var request = URLRequest(url: url!) request.httpMethod = "GET" //config let config = URLSessionConfiguration.ephemeral config.waitsForConnectivity = true //session let session = URLSession(configuration: config) //connect let task = session.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if let error = error { completion(false, error.localizedDescription) } else { if let data = data { let json = data.toJSON() let feed = json["feed"] as! JSON let results = feed["results"] as! [JSON] for item in results { let name = item["name"] as! String self.names.append(name) } completion(true, "") } else { completion(false, "Data format is error.") } } } } task.resume() print("DONE") }
Build và cảm nhận kết quả.
3.5. Networking Model
Tất nhiên, chúng ta sẽ không phải ngồi code 1 đống code cho mỗi lần tương tác với API. Để tiết kiện thời gian, chúng ta cần phải tạo ra Model
riêng để sử dụng. Và nó phải bao quát nhiều nhất các trường hợp tương tác có thể nhất.
Tạo class cho đối tượng cần parse
. New 1 file mới tên là Music
. Trong này:
- Các properties là các dữ liệu chúng ta cần cho việc hiển thị
- Khởi tạo đối tượng từ một JSON.
import Foundation import UIKit final class Music { var id: String var artistName: String var releaseDate: String var name: String var artworkUrl100: String var thumbnailImage: UIImage? init(json: JSON) { self.id = json["id"] as! String self.artistName = json["artistName"] as! String self.releaseDate = json["releaseDate"] as! String self.name = json["name"] as! String self.artworkUrl100 = json["artworkUrl100"] as! String } }
Custom lại một enum
để phục vụ cho Error
. Cái này tạm thời bạn chỉ cần sử dụng là được rồi. Vì:
Error
, của hệ thống là một Protocol. Nên nếu muốn dùng thì bạn sẽ cần phải kế thừa lại nó.- Sử dụng
enum
, để đơn giản hoá việc xử lí các error. Có thời gian mình sẽ trình bày nó riêng một chủ đề khác.
enum APIError: Error { case error(String) case errorURL var localizedDescription: String { switch self { case .error(let string): return string case .errorURL: return "URL String is error." } } }
Tạo mới một class tên là Networking
. Áp dụng singleton vào class này.
import Foundation final class Networking { //MARK: - singleton private static var sharedNetworking: Networking = { let networking = Networking() return networking }() class func shared() -> Networking { return sharedNetworking } //MARK: - init private init() {} //MARK: - request //MARK: - downloader }
Viết một function mới cho việc request
. Với
urlString
là tham số cho link requestcompletion
là closure cho call back.- Chỉ trả về
data
vàerror
kiểu Optional
func request(with urlString: String, completion: @escaping (Data?, APIError?) -> Void) { // code sau nha }
Move code hay viết lại code của phần tương tác với Server tương tự như trên.
func request(with urlString: String, completion: @escaping (Data?, APIError?) -> Void) { guard let url = URL(string: urlString) else { let error = APIError.error("URL lỗi") completion(nil, error) return } let config = URLSessionConfiguration.ephemeral config.waitsForConnectivity = true let session = URLSession(configuration: config) let task = session.dataTask(with: url) { (data, response, error) in DispatchQueue.main.async { if let error = error { completion(nil, APIError.error(error.localizedDescription)) } else { if let data = data { completion(data, nil) } else { completion(nil, APIError.error("Data format is error.")) } } } } task.resume() }
Mở file HomeViewModel
tiến hành thay đổi một số điểm sau:
- Tạo 1 array với đối tượng
Music
var musics: [Music] = []
- Viết lại function
loadAPI
, sử dụng model vừa tạo. Tạm thời đặt tên nó làloadAPI2
(cho khỏi trùng tên)
func loadAPI2(completion: @escaping Completion) { let urlString = "https://rss.itunes.apple.com/api/v1/us/itunes-music/hot-tracks/all/100/explicit.json" Networking.shared().request(with: urlString) { (data, error) in if let error = error { completion(false, error.localizedDescription) } else { if let data = data { let json = data.toJSON() let feed = json["feed"] as! JSON let results = feed["results"] as! [JSON] for item in results { let music = Music(json: item) self.musics.append(music) completion(true, "") } } else { completion(false, "Data format is error.") } } } }
Mở file HomeViewController
và update lại phần delegate & datasource của TableView với dữ liệu là đối tượng Music
. Tiến hành build và cảm nhận kết quả. Tham khảo code như sau:
extension HomeViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewmodel.musics.count } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 200 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! HomeCell let item = viewmodel.musics[indexPath.row] cell.titleLabel.text = item.name cell.artistNameLabel.text = item.artistName return cell } }
Dữ liệu đã có cho title bài nhạc và tên tác giả. Nhưng thiếu đi phần hình ảnh. Tiếp tục chuyển sang phần hình ảnh.
3.6. Downloader
Downloader chính là công việc tương tác với API. Nhưng thay vì format dữ liệu thành JSON, thì chúng ta biến nó thành
UIImage
.
Mở file Networking
, tiến hành clone lại function request. Nhưng thay đổi để phù hợp việc biến đổi thành UIImage
.
- Chú ý việc biển đổi
Data
thànhUIImage
- Xem tham số
completion
- Xem tham số
let image = UIImage(data: data)
func downloadImage(url: String, completion: @escaping (UIImage?) -> Void) { guard let url = URL(string: url) else { completion(nil) return } let config = URLSessionConfiguration.default config.waitsForConnectivity = true let session = URLSession(configuration: config) let task = session.dataTask(with: url) { (data, response, error) in DispatchQueue.main.async { if let _ = error { completion(nil) } else { if let data = data { let image = UIImage(data: data) completion(image) } else { completion(nil) } } } } task.resume() }
Tiến hành download image cho cell của TableView. Tại hàm cell For Row
, tiến hành edit đoạn code sau:
- Cứ mỗi lần chạy qua từng
row
. Thì sẽ lấy được thứ tự của item trong array bằngindexPath
- Kiểm tra item có image chưa. Nếu chưa thì tiến hành gọi download.
- Nhận được image thì cập nhật lại cho
cell
vàitem
.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! HomeCell let item = viewmodel.musics[indexPath.row] cell.titleLabel.text = item.name cell.artistNameLabel.text = item.artistName if item.thumbnailImage != nil { cell.thumbnail.image = item.thumbnailImage } else { cell.thumbnail.image = nil //downloader Networking.shared().downloadImage(url: item.artworkUrl100) { (image) in if let image = image { cell.thumbnail.image = image item.thumbnailImage = image } else { cell.thumbnail.image = nil } } } return cell }
Cần chú ý khi làm việc với TableView, điều quan trọng là có if
thì phải có else
. Nên cần việc quản lý các trường hợp, lỗi và không có ảnh. Thì phải xét ảnh của cell là nil
.
Nếu thành công, bạn cần cập nhật lại ảnh cho đối tượng Music
, để không cần gọi request lấy ảnh từ Server nữa. Build và cảm nhận kết quả.
Okay, giờ thì bạn đã có đủ skill để giết hết các yêu cầu liên quan tới tương tác networking. Ngoài ra, còn bổ sung thêm kiến thức downloader để download image cho cell của TableView rồi. Bạn có thể checkout mã nguồn tại đây. Chúc bạn thành công!
Tạm kết
- Các khái niệm dùng trong Networking
- Mô hình cho việc tương tác
- Sự hoạt động của URLSession và các đàn em của nó
- Parser Data
- Download image cho cell của TableView
Cảm ơn bạn đã đọc bài viết này!
Related Posts:
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- Charles Proxy – Phần 1 : Giới thiệu, cài đặt và cấu hình
- Complete Concurrency với Swift 6
- 300 Bài code thiếu nhi bằng Python – Ebook
- Builder Pattern trong 10 phút
- Observer Pattern trong 10 phút
- Memento Pattern trong 10 phút
- Strategy Pattern trong 10 phút
- Automatic Reference Counting (ARC) trong 10 phút
- Autoresizing Masks trong 10 phút
- Regular Expression (Regex) trong Swift
You may also like:
Archives
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)