
Привіт. У цій статті я спробую дати кілька порад щодо написання коду для початківців у iOS-розробці. Я не буду занурюватися в деталі, оскільки це займе багато часу (чесно кажучи, мені просто ліньки). Суть цієї статті полягає в тому, щоб допомогти створити хороші та стабільні додатки; продемонструвати, як це зробити, а як не варто; розвинути вашу звичку писати якісний та зрозумілий код.
UI
Стилі
Розпочнемо зі стилів, отриманих від дизайнера. Ви точно не будете пам’ятати щоразу, який rgb-код потрібно використовувати для конкретної кнопки? Напишіть розширення для UIColor
та UIFont
:
extension UIColor { class func blueGradientColor()-> UIColor {return UIColor(red:65.0/255.0, green:196.0/255.0, blue:254.0/255.0, alpha:1.0)} class func orangeGradient()-> UIColor {return UIColor(red:246.0/255.0, green:117.0/255.0, blue:68.0/255.0, alpha:1.0)} class func orangeGradient30opacity()-> UIColor {return UIColor(red:246.0/255.0, green:117.0/255.0, blue:68.0/255.0, alpha:0.3)} … extension UIFont { class func L36()-> UIFont {return UIFont.systemFont(ofSize:36.0, weight: UIFontWeightLight)} class func M26()-> UIFont {return UIFont.systemFont(ofSize:26.0, weight: UIFontWeightMedium)} class func L26()-> UIFont {return UIFont.systemFont(ofSize:26.0, weight: UIFontWeightLight)} class func M20()-> UIFont {return UIFont.systemFont(ofSize:20.0, weight: UIFontWeightMedium)} …
Ви також можете використовувати:
titleLbl.font = UIFont.R18() titleLbl.textColor = UIColor.blueGreyColor()
Якщо вам потрібно буде використовувати інший колір/стиль пізніше, ви зміните лише один файл, уникаючи пошуку рядків проекту з ним по всьому проекту.
UIAppearance
Далі. Уявімо, що ви хочете, щоб усі title
у navigationBar
мали один і той же стиль. Звичайно, ви можете написати один і той же код у всіх контролерах подань. Але краще використати той же підхід, що й у css: ми створюємо стилі заздалегідь, а потім система їх використовує. Метод appearance()
:
let barBtn = UIBarButtonItem.appearance() barBtn.setTitleTextAttributes([NSForegroundColorAttributeName : UIColor.white(), NSFontAttributeName : UIFont.L17()], for: UIControlState())
Усі елементи UIKit мають метод appearance()
. Ось кастомний gradient
, до якого цей метод також застосовується:
let navBar = CRGradientNavigationBar.appearance() let colors =[ UIColor.orangeGradient(), UIColor.pinkGradient()] navBar.setBarTintGradientColors(colors) navBar.isTranslucent =false navBar.titleTextAttributes =[NSForegroundColorAttributeName : UIColor.white(), NSFontAttributeName : UIFont.M17()]
Групуйте такі речі та помістіть їх в один метод, назвемо його interfaceAppearance()
і помістимо його десь в додатку: application: didFinishLaunchingWithOptions
.
VCManager. sharedInstance
У моєму останньому проекті я зіткнувся з проблемою частих викликів різних контролерів з най«невідповідніших» місць :). Наприклад, у мене є контролер demoVC
, і я часто звертаюся до його властивостей. Здається, що легко написати два рядки коду, але я не хотів би писати їх постійно, оскільки архітектура програми може завжди змінюватися. Створіть синглтон:
class VCManager:NSObject{ fileprivate override init(){}static let sharedInstance = VCManager() override func copy()-> Any { fatalError("Ви не можете використовувати метод copy у синглтоні!")} …
Потім ми додаємо методи, які він може реалізувати:
func demoVC()-> DemoVC { let navVC = tabBarVC.viewControllers?[2] as! UINavigationController return navVC.viewControllers.first as! DemoVC } func logout(){if let tabBarVC = tabBarVC { let vc = tabBarVC.storyboard?.instantiateViewController(withIdentifier:"WelcomeVC") UIApplication.shared.keyWindow?.rootViewController = vc //закриття сокета SocketManager.sharedInstance.logoutFromWebSocket() //видалення покупок KeychainManager.sharedInstance.removeAll()}}
NSLayoutConstraint vs SnapKit
Ще один момент стосовно UI — це NSLayoutConstraint
. Ось приклад використання цього "кошмару" у коді:
private func setupConstraints(){ self.backImView.translatesAutoresizingMaskIntoConstraints =false self.textLbl.translatesAutoresizingMaskIntoConstraints =false self.titleLbl.translatesAutoresizingMaskIntoConstraints =false self.imageView?.translatesAutoresizingMaskIntoConstraints =false self.button?.translatesAutoresizingMaskIntoConstraints =false self.customView?.translatesAutoresizingMaskIntoConstraints =false let backImLeading = NSLayoutConstraint(item: self.backImView, attribute: .Leading, relatedBy: .Equal, toItem: self, attribute: .Leading, multiplier:1.0, constant:15.0) let backImTop = NSLayoutConstraint(item: self.backImView, attribute: .Top, relatedBy: .Equal, toItem: self, attribute: .TopMargin, multiplier:1.0, constant:20.0) …
Ось як виглядає код зі SnapKit:
//constraints imageView.snp_makeConstraints(closure:{(make)in make.left.equalTo(customView).offset(15.0) make.top.equalTo(customView).offset(15.0) make.size.equalTo(CGSize(width:90.0, height:57.0))}) whereLbl.snp_makeConstraints(closure:{(make)in make.left.equalTo(imageView.snp_right).offset(10.0) make.top.equalTo(imageView) make.right.equalTo(customView).offset(-15.0)}) …
Відвідайте офіційний сайт, ознайомтеся з відповідними документами та напишіть зрозумілий і компактний код, який потім можна буде легко виправити.
Архітектура
Менеджери залежностей
Cocoapods, Carthage чи стандартний Package Manager? Уявімо, що вам потрібно використовувати сторонні рішення, які вимагають певних залежностей. Звісно, ви можете переглядати сайт git.hub, шукати залежності, копіювати їх у свій проект, потім з'ясувати, що ви використали неправильну версію і, зрештою, почати знову. Чому? Замість цього ви можете скористатися одним із трьох згаданих менеджерів, які зроблять усе, а в разі оновлення вони також автоматично оновлять залежності.
У своїй роботі я надаю перевагу Cocoapods. По-перше, їх використовує Google, а це багато значить. По-друге, користуватися Cocoapods дуже просто, і всі сторонні фреймворки використовують цей менеджер.
Інструкція для Cocapods тут.
Варто зазначити щодо Cocoapods. Уявімо, що ви використовуєте swift 2.3 у своєму проекті і ще не перейшли на swift 3.0. Наприклад, ви використовуєте Gloss, який вже був оновлений до версії, що підтримує swift 3.0, на момент публікації цієї статті. Що робити? Якщо ви напишете pod 'Gloss' у Podfile, з'являться помилки. Перейдіть на GitHub, знайдіть Gloss, перевірте, чи є у них гілка, що підтримує swift 2.3. Потім напишіть наступне в Cocoapods:
pod 'Gloss', :git => 'https://github.com/hkellaway/Gloss', :branch => ‘swift_2.3'
Це означає наступне: використовуйте Gloss, що підтримує swift 2.3 :)
Або просто встановіть версію для Gloss, щоб оновлювати:
pod 'Gloss', '< 1.0'
Однак усі нові функції більше не будуть доступні для вас у цьому випадку:(
Шукаєте більше інформації? Ось матеріал про Package Manager та Carthage.
Gloss, JSON-мапінг
Проект розташований тут. Він створений для JSON-мапінгу та створення моделей.
Приклад використання:
let dict = obj as![AnyHashable:Any] ChallengeModel(json: dict as! Gloss.JSON)! …
Тут ми створюємо об'єкт ChallengeModel
з об'єкта [AnyHashable:Any]
.
Отже, клас моделі виглядає наступним чином (до речі, ви також можете використовувати структуру value type
):
class ChallengeModel: Decodable { var author: AuthorModel? var categories:[CategoriesModel]? var created_at: UInt? var id: String? var opponent: AuthorModel? var questions:[QuestionModel]? required init?(json: JSON){ author ="author"<~~ json categories ="categories"<~~ json created_at ="created_at"<~~ json id="id"<~~ json opponent ="opponent"<~~ json questions ="questions"<~~ json …
Ми реалізуємо протоколи Decodable
, імплементуємо init?(json: JSON)
, і на цьому все! В результаті ми отримуємо готову модель. Не використовуйте NSJSONSerializer
та подібні речі, Gloss робить це за вас :)
Досить часто я також користуюсь SwiftyJSON. Ви можете прочитати та зрозуміти, які переваги він має в порівнянні з традиційним підходом.
Moya
Я не використовував його в чистому вигляді (без функціонального програмування та MVVM), але все ж рекомендую спробувати Moya. Це абстрактний «шар», де ви можете зберігати всі свої кінцеві точки.
Створіть enum
і назвіть його на свій розсуд:). Приклад використання:
import Moya enum Endpoints {case authorization(client_id: String, client_secret: String, grant_type: String, fb_access_token: String, refresh_token: String?, device_type: String, device_id: String)case updateRefreshToken(client_id: String, client_secret: String, grant_type: String, refresh_token: String, device_type: String, device_id: String)case updateUserName(name: String)case updatePhoto(photo: UIImage)case getCategories()case startQuestions(categories:[String], search_type: String) …
Пишемо extension
:
extension Endpoints: TargetType { var baseURL: URL {return URL(string: “http://master.you_base.url.com”)! } var path: String {switch self {case .authorization, .updateRefreshToken:return “/api/some_endpoint” case .updateUserName:return"/api/some_endpoint"case .updatePhoto:return"/api/some_endpoint"case .getCategories:return"/api/some_endpoint" … var method: Moya.Method {switch self {case .authorization, .updateRefreshToken, .getIsFriend:return .GET case .updatePhoto, .startQuestions, .postPurchases:return .POST var parameters:[String: Any]? {switch self {case .getUserFriends(let facebook_friends, let page):return["page":page, "facebook_friends":facebook_friends] case .getAnswerStatistics(let categories):return[“categories":categories] … //для юніт-тестів var sampleData: Data { return "".data(using: .utf8)! } var task: Task { switch self { case .updatePhoto(let photo): let imData = UIImagePNGRepresentation(photo) let multipartFormData = Moya.MultipartFormData.init(provider: Moya.MultipartFormData.FormDataProvider.data(imData!), name: "photo", fileName: "file.webp", mimeType: "image/png") return Task.upload(UploadType.multipart([multipartFormData])) default: return Task.request } } …
Щоразу, коли вам потрібно отримати доступ до сервера, викликайте відповідну функцію в класі viewModel:
func getCategories()-> Observable<[CategoriesModel]?> {return ProviderManager.sharedInstance.jsonProvider .request(Endpoints.getCategories()) .debug() .mapJSON() .map {(obj)-> [CategoriesModel]? in print(obj) var categories =[CategoriesModel]() for item in(items as!NSArray){ categories.append(CategoriesModel(json: item as! Gloss.JSON)!)} return categories }}
Endpoints.getCategories()
— це перелік, визначений вище.
Я використовую ProviderManager
в цьому прикладі. Це синглтон, який має два поля:
defaultProvider = RxMoyaProvider<endpoints>() jsonProvider = RxMoyaProvider<endpoints>(endpointClosure: closure)</endpoints></endpoints>
Перше поле використовується без параметрів, друге має занадто багато з них, щоб бути показаним тут. Але суть у тому, що в кінці ви отримаєте розпарсовану модель і необхідні дані. Я продовжую використовувати цей підхід протягом усього циклу роботи над цим проектом. Чому ProviderManager
є синглтоном? ARC видалить об'єкт, якщо у нього не залишиться посилань.
«Власний» DataManager
Якщо ваш додаток використовує кілька ендпоінтів, ви можете використовувати інший підхід. Створіть клас DataManager
і включіть наступні методи:
class func getProfileUserId(userId: Int, handler: UserProfileHandler){ let url ="\(baseURL)/user/profile/\(userId)" NetworkManager.requestWith(url, reqMethod: .GET, dataToSend:["userId":userId], uploadImage:nil, handler:{(json, error)in print(json) if json !=nil&& error ==nil{ …
Клас NetworkManager
є приватним класом
, який використовує Alamofire
в моєму випадку, але може використовувати щось інше. Головна суть полягає в тому, що якщо щось не працює для вас, ви можете змінити реалізацію класу, але зберегти інтерфейс незмінним!
private class NetworkManager { class func requestWith(url: String, reqMethod: Alamofire.Method, dataToSend:[String:AnyObject]?, uploadImage: UIImage?, handler: SuccessHandler){ var header:[String:String]? if DataManager.getToken().characters.count > 0{ let langId =NSLocale.currentLocale().objectForKey(NSLocaleLanguageCode) as! String let countryId =NSLocale.currentLocale().objectForKey(NSLocaleCountryCode) as! String let language ="\(langId)-\(countryId)" header =["Authorization":"Bearer \(DataManager.getToken())", "AcceptLanguage":language]}else{ header =nil} Alamofire.request(reqMethod, url, parameters: dataToSend, headers: header).responseJSON { response inswitch response.result {case .Success(let JSON): let jsonS = SwiftyJSON.JSON(JSON) handler(json: jsonS, error:nil) case .Failure(let error): handler(json:nil, error: error)}}}}
Шаблон «Фасад»
Наприкінці цього розділу я хотів би поговорити про шаблон «фасад» та про те, як його використовувати на практиці. Уявімо, що сервер повертає time stamp (секунди з 1 січня 1970 року (UTC))
, але необхідно відобразити час у наступному форматі: якщо потрібна дата — сьогодні, то години: хвилини, якщо вчора — просто написати Вчора, якщо пізніше — відобразити дату.
Чи не варто це писати в контролері щоразу? Створіть окремий клас DateManager
і додайте необхідні методи:
func gmtTimeInSeconds(date: Date)-> UInt {return UInt(date.timeIntervalSince1970)} func convertSecondsToDate(seconds: UInt)-> String { let date = Date(timeIntervalSince1970: TimeInterval(seconds)) let formatter = DateFormatter() formatter.timeZone =NSTimeZone.local ifNSCalendar.current.isDateInToday(date){ formatter.dateFormat ="HH:mm"return formatter.string(from: date) }elseifNSCalendar.current.isDateInYesterday(date){return"Вчора" }else{ formatter.dateFormat ="MM" let monthIndex = formatter.string(from: date) let monthName = formatter.standaloneMonthSymbols[Int(monthIndex)!] formatter.dateFormat ="dd"return"\(monthName) \(formatter.string(from: date))"}} func convertTimeIntervalInMinutesSeconds(time: UInt)-> String { let minutes = Int(time/60) let seconds =(time%60) if seconds < 10{return"\(minutes):0\(seconds)"}return"\(minutes):\(seconds)"}
Цей клас може бути або синглтоном, або простим. У другому випадку краще використовувати методи класу (class func
) або створювати екземпляр цього класу щоразу.
Розглянемо, як працювати з сокетами. Для цього я використовую перевірений фреймворк SocketRocket, створений Facebook. Тоді ми знову пишемо фасад, і вам обов'язково потрібно використовувати синглтон на цьому етапі (ура!):
import Foundation import SocketRocket class SocketManager:NSObject{ fileprivate override init(){}static let sharedInstance = SocketManager() override func copy()-> Any { fatalError("Ви не можете використовувати метод копіювання для синглтона!")} //------------------------------ private var webSocket: SRWebSocket? var connectionWasOpened =true var lastUserId = UserDefaults.standard.string(forKey: Constants.myIdKey.rawValue) func openNewConnection(userId: String){if webSocket !=nil{ webSocket?.close() webSocket?.delegate =nil webSocket =nil} lastUserId = userId let urlStr ="ws://your_url/\(lastUserId ?? "")" webSocket = SRWebSocket(url: URL(string: urlStr)) webSocket?.delegate = self webSocket?.open()} func closeConnection(){ webSocket?.close()} func logoutFromWebSocket(){ webSocket?.close() webSocket?.delegate =nil webSocket =nil} func reconnect(){if lastUserId !=nil&&(webSocket?.readyState != SRReadyState.CONNECTING)&&(webSocket?.readyState != SRReadyState.OPEN){ openNewConnection(userId: lastUserId!)}} deinit { logoutFromWebSocket()}} extension SocketManager: SRWebSocketDelegate { func webSocketDidOpen(_ webSocket: SRWebSocket!){ webSocket?.sendPing(nil) connectionWasOpened =true} func webSocket(_ webSocket: SRWebSocket!, didFailWithError error: Swift.Error!){if error !=nil{ print("ПОМИЛКА СОКЕТА: \(error.localizedDescription)")}if connectionWasOpened ==true{ connectionWasOpened =false reconnect()}else{ DispatchQueue.main.asyncAfter(deadline: .now()+15, execute:{ self.reconnect()})}} func webSocket(_ webSocket: SRWebSocket!, didCloseWithCode code: Int, reason: String!, wasClean: Bool){ print("ПОМИЛКА СОКЕТА: \(reason)") reconnect()} func webSocket(_ webSocket: SRWebSocket!, didReceivePong pongPayload: Data!){ print("ВІДПОВІДЬ PONG: \(pongPayload.description)")if webSocket.readyState == SRReadyState.OPEN { webSocket.sendPing(nil)}} func webSocket(_ webSocket: SRWebSocket!, didReceiveMessage message: Any!){ print("ПОВІДОМЛЕННЯ: \(message)") EventManager.sharedInstance.performEvent(data: message)}}
Ви можете безпечно (або ні) використовувати цей клас у ваших проектах. Просто поділіться невеликою часткою (50-95%) зі Stfalcon і продовжуйте працювати з задоволенням :). Жартую, надішліть все на мій банківський рахунок.
SecureNSUserDefaults
Ще один важливий момент. Ми всі любимо використовувати UserDefaults
. Це зручний та надійний клас. Проте, якщо користувач використовує пристрій з джейлбрейком, він/вона може легко отримати доступ до даних. Тому переконайтеся, що дані зашифровані:
#Keychain pod 'SecureNSUserDefaults', '~> 1.0.1' pod 'CocoaSecurity'
Ця розширення
створена для UserDefaults
для шифрування ваших даних. Тому вам не слід зберігати важливі дані в UserDefaults
, оскільки це лише для налаштувань.
Відсутнє з'єднання з Інтернетом
Немає підключення до Інтернету. Чи будемо ми інформувати користувача про це, чи нехай він здогадує, коли застосунок зламається? Скажімо, що ми повинні повідомити користувача, або принаймні попередити :). Використайте стороннє рішення під назвою UHBConnectivityManager
(доступне на Cocoapods). У AppDelegate, application: didFinishLaunchingWithOptions:
: напишіть наступне:
//підключення до Інтернету UHBConnectivityManager.shared().registerCallBack({[weak self](status: ConnectivityManagerConnectionStatus)inif status == ConnectivityManagerConnectionStatusConnected { print("Інтернет підключено") SocketManager.sharedInstance.reconnect() self?.alertVC.dismiss(animated:true, completion:nil) }else{ print("Немає з'єднання") SocketManager.sharedInstance.closeConnection() VCManager.sharedInstance.topViewController()?.present((self?.alertVC)!, animated:true, completion:nil)}}, forIdentifier: self.memoryAddress())
alertVC
— це властивість:
fileprivate var alertVC: UIAlertController ={ let vc = UIAlertController(title:"Увага", message:"Немає підключення до Інтернету", preferredStyle: .alert) let action = UIAlertAction(title:"Добре", style: .default, handler:nil) vc.addAction(action)return vc }()
Нарешті, я хотів би поділитися своїм досвідом роботи з UIWebView
. У моєму останньому проекті мені довелося створити контролер, який активно використовував UIWebView
. Це екран пояснення, який показує результат тесту, пройденого гравцем. Якщо відповідь неправильна, з'являється червона кнопка з відповіддю, і відображається правильна відповідь з поясненням. Звичайно, ви можете використовувати UILabel
і UIButton
, і помістити все це в клітинку UIScrollView
, яка включає пагінацію (є кілька запитань). Спочатку це було зроблено саме так, але (знову це «але» !!!) було необхідно використовувати MathML
в коді (для відображення математичних формул), і UILabel
не може відобразити це належним чином. Більше того, UIWebView
має вбудований UIScrollView
, тому не було необхідності обчислювати висоту для кожного елемента :)
Я показую шматок коду для клітинки:
```htmllet pathToCss = Bundle.main.path(forResource:"styles", ofType:"css") cell.webView.loadHTMLString(HtmlHelper.fontExplanation(questionTitle: questionTitle, question: question, wrongTitle: item!.is_correct!==false ? "ВАША ВІДПОВІД — НЕПРАВИЛЬНО":"", wrongAnswer: item!.is_correct!==false ? wrongText :nil, correctTitle: item!.is_correct!==false ? "ПРАВИЛЬНА ВІДПОВІДЬ":"ВАША ВІДПОВІДЬ - ПРАВИЛЬНО", correctAnswer: correctText, explanationTitle:"ПОЯСНЕННЯ", explanation: explanationText, isCorrect: item!.is_correct!), baseURL: URL(string: pathToCss!))
Цікаво, що pathToCss
— у проекті я також додав css-стилі та використовував шлях до них як baseURL
. Метод класу HtmlHelper fontExplanation
:
class func fontExplanation(questionTitle: String, question: String, wrongTitle: String?, wrongAnswer: String?, correctTitle: String, correctAnswer: String, explanationTitle: String, explanation: String, isCorrect: Bool)-> String { return" <meta charset='\"UTF-8\"'> <meta name='\"viewport\"' content='\"width=device-width,' user-scalable=\"no,\" initial-scale=\"1.0,\" maximumscale=\"1.0,\" minimum-scale='1.0\"'> <meta http-equiv='\"X-UA-Compatible\"' content='\"ie=edge\"'> <title>Document</title> <link rel='\"stylesheet\"' href=\"%5C%22styles.css%5C%22\"> <p class='\"answer-text\"'>\(question)</p> <div class='\"label\"'>\(correctTitle)</div> <div class='\"answer' answer_right>\(correctAnswer)</div> <div class='\"label\"'>\(explanationTitle)</div> <p class='\"answer-text\"'>\(explanation)</p>```