Всем привет. В этой статье я попытаюсь дать небольшие советы по написанию кода для начинающих iOS-девелоперов. Я не буду особо углубляться, так как это займет много времени (если честно, то мне просто лень). Суть этой статьи: помочь в создании хороших, стабильных приложений; продемонстрировать, как делать нужно, а как не стоит; выработать у вас привычку писать хороший, понятный код.
UI
Стили
Начнем со стилей, который приходят к нам от дизайнера. Не будете же вы каждый раз вспоминать, какой rgb-код нужно использовать для той или иной кнопочки? Пишем extension
для 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
были в одном стиле. Можно, конечно, прописывать один и тот же код во всех view controllers. Но было бы лучше использовать такой подход, как в css: мы заранее задаем стили а потом система сама их использует. Метод appearance()
:
let barBtn = UIBarButtonItem.appearance() barBtn.setTitleTextAttributes([NSForegroundColorAttributeName : UIColor.white(), NSFontAttributeName : UIFont.L17()], for: UIControlState())
Метод appearance()
есть у всех UIKit-элементов. Вот костомный 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("You are not allowed to use copy method on singleton!")} …
И добавляем в него те методы, которые, по логике, он должен реализовывать:
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 //socket close SocketManager.sharedInstance.logoutFromWebSocket() //remove purshases 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)}) …
Заходите на официальный сайт, читайте доку, пишите лаконичный и понятный код, который легко можно потом поправить.
Architecture
Dependency managers
Cocoapods, Carthage или стандартный Package Manager? Предположим, что нужно использовать сторонние решения, которые, в свою очередь, требует еще каких-то зависимостей. Конечно, можно серфить по гиту, искать зависимости, копировать себе в проект, потом узнавать, что записали не ту версию, что вам нужно, и начинать процесс с начала. Но зачем? Можно ведь использовать один из трех выше названных менеджеров, которые сами все сделают, и в случае обновления также автоматически обновят зависимости.
В своей работе я отдаю предпочтение Cocoapods. Во-первых, их используют Google, а это уже о многом говорит. Во-вторых, использовать cocoapods очень легко, и все сторонние фреймворки юзают этот менеджер.
Инструкция по Cocapods произрастает тут.
Важное замечание по Cocoapods. Предположим, вы используете в проекте swift 2.3 и еще не перешли на swift 3.0. Используете, например, Gloss, который к моменту написания статьи уже обновился до версии, поддерживающей swift 3.0. Как же быть? Если прописать в Podfile pod ‘Gloss'
, полезут ошибки. Лезем на github, ищем Gloss, смотрим, есть ли у них branch, который поддерживает 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 mapping
Проект располагается здесь. Предназначен он для мапинга 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
В чистом виде (без functional programming и mvvm) я не использовал, но все же советую попробовать Moya. Это абстрактный «слой», в котором можно хранить все ваши endpoints.
Создаем 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: “https://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] … //for unit test 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()
и есть enum
, определенный выше.
В этом примере я использую ProviderManager
. Это сингелтон, у которого есть два поля:
defaultProvider = RxMoyaProvider<Endpoints>() jsonProvider = RxMoyaProvider<Endpoints>(endpointClosure: closure)
Первое используется без параметров, второе наоборот — имеет их слишком много, чтобы тут показывать. Но суть в том, что на выходе получите уже распаршенную модель и те данные, которые вам нужны. И этот подход у меня сохраняется на протяжении всей работы над этим проектом. Почему ProviderManager
сингелтон? ARC удалит объект, если на него нет ссылок.
«Свой» DataManager
Если ваше приложение использует всего несколько endpoint, можете воспользоваться иным подходом. Пишем класс, например, 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
— это private class
, который в моем случае использует Alamofire
, но мог бы использовать что-то еще. Суть в том, что если что-то ваc не устроило, можете поменять реализацию класса, но оставить нетронутым интерфейс!
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 (seconds since Jan 01 1970.(UTC))
, а отображать надо время в следующем формате: если дата сегодня — часы:минуты, если вчера — просто писать Yesterday, если позже — отображать число.
Не будем же мы каждый раз писать это в контроллере? Создаем отдельный класс, скажем, 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"Yesterday" }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. Опять же пишем фасад, и тут уже точно нужно использовать сингелтон (yeaaaaaah!):
import Foundation import SocketRocket class SocketManager:NSObject{ fileprivate override init(){}static let sharedInstance = SocketManager() override func copy()-> Any { fatalError("You are not allowed to use copy method on singleton!")} //------------------------------ 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("SOCKET ERROR: \(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("SOCKET ERROR: \(reason)") reconnect()} func webSocket(_ webSocket: SRWebSocket!, didReceivePong pongPayload: Data!){ print("PONG PAYLOAD: \(pongPayload.description)")if webSocket.readyState == SRReadyState.OPEN { webSocket.sendPing(nil)}} func webSocket(_ webSocket: SRWebSocket!, didReceiveMessage message: Any!){ print("MESSAGE: \(message)") EventManager.sharedInstance.performEvent(data: message)}}
Можете смело (или не очень) использовать этот класс в своих проектах. Небольшой процент (50-95%) закидываете на счет Stfalcon и работаете себе в удовольствие :). Шутка, все закидывайте на мой счет.
SecureNSUserDefaults
Еще один важный момент. Мы все любим использовать UserDefaults
. Простой в использовании и надежный класс. Вроде бы, но если пользователь будет использовать jailbreaked device, то сможет легко получить доступ к данным. Так что позаботьтесь о том, чтобы данные шифровались:
#Keychain pod 'SecureNSUserDefaults', '~> 1.0.1' pod ‘CocoaSecurity'
Это extension
для UserDefaults
, которое шифрует ваши данные. Так что не стоит хранить важные данные в UserDefaults
, он только для настроек.
No internet connection
Пропал интернет. И что, скажем пользователю об этом или пускай догадается сам, когда упадет приложение? Допустим, что сказать все-таки надо или хотя бы предупредить :). Используем стороннее решение под названием UHBConnectivityManager
(доступно на Cocoapods). В AppDelegate, applicaiton: didFinishLaunchingWithOptions:
пишем следующее:
//internet connection UHBConnectivityManager.shared().registerCallBack({[weak self](status: ConnectivityManagerConnectionStatus)inif status == ConnectivityManagerConnectionStatusConnected { print("Internet connected") SocketManager.sharedInstance.reconnect() self?.alertVC.dismiss(animated:true, completion:nil) }else{ print("No connection") SocketManager.sharedInstance.closeConnection() VCManager.sharedInstance.topViewController()?.present((self?.alertVC)!, animated:true, completion:nil)}}, forIdentifier: self.memoryAddress())
alertVC
— это property:
fileprivate var alertVC: UIAlertController ={ let vc = UIAlertController(title:"Warning", message:"No internet connection", preferredStyle: .alert) let action = UIAlertAction(title:"Ok", style: .default, handler:nil) vc.addAction(action)return vc }()
Напоследок, хотел бы поделиться опытом по работе с UIWebView
. На последнем проекте пришлось создавать контролер, который активно использует UIWebView
. Это Explanation Screen, показывающий результат пройденного игроком теста. Если ответили неправильно, рисуется красная кнопочка с ответом, показывается правильных ответ и объяснение. Можно было бы, конечно, использовать UILabel
и UIButton
, и поместить все это на UIScrollView
ячейки, в которой включена пагинация (вопросов больше, чем один). Так изначально и было сделано, но (опять это но!!!) в коде нужно было использовать MathML
(для показа математических формул), а UILabel
не может его отображать правильно. Тем более, что UIWebView
уже имеет встроенный UIScrollView
, так что рассчитывать высоту для каждого элемента не пришлось :).
Показываю кусок кода для ячейки:
let pathToCss = Bundle.main.path(forResource:"styles", ofType:"css") cell.webView.loadHTMLString(HtmlHelper.fontExplanation(questionTitle: questionTitle, question: question, wrongTitle: item!.is_correct!==false ? "YOUR ANSWER — WRONG":"", wrongAnswer: item!.is_correct!==false ? wrongText :nil, correctTitle: item!.is_correct!==false ? "CORRECT ANSWER":"YOUR ANSWER - CORRECT", correctAnswer: correctText, explanationTitle:"EXPLANATION", 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"<!doctype html> <html lang=\"en\"> <head> <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=\"styles.css\"> </head> <body> <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> </div> </body> </html>"
Вот так вот, используя небольшие знания по верстке (которых у меня 0, спасибо Руслану ;)) и UIWebView
, можно отформатировать любой текст, даже MathML
, и представить это все в очень красивой форме.
Swift
Ну, как вы уже, видимо, поняли из название и моего кода, Swift — это тот язык, что я использую везде в своей работе. Раньше использовал Objective-C, но время его прошло, и остался он лишь для поддержки legacy-проектов. Хотя я видел программеров, которые до сих пор пишут на obj-c, но это отдельная история и не мне их судить. Ведь конечный результат — это двоичный код для машины, и как ты его написал, если он работает, уже мало кого волнует. Но вот поддержка таких проектов — крайне сложное занятие, и никто за это не хочет браться. Если языки С и С++ будут оставаться всегда (так как, по сути, лежат в ядре любого языка), то obj-c может полностью исчезнуть, поскольку swift уже его обогнал по всем показателям :)
С тех пор, как Apple сделали swift open source, его уже используют в других областях. Например, пишут на нем под raspberry pi 3! Мне сложно судить что и как у них там обстоит, так как я не работаю в этой сфере, но пробовал нечто другое, а именно Vapor.
Vapor — это framework для backend, написанный полностью на swift. Полезный ресурс и как установить — читайте тут. Официальная документация: вот она. Я лишь покажу небольшой пример, чтобы вам стало понятно, хотя бы приблизительно, как с этим работать.
После установки и создания проекта запустите из консоли команду vapor xcode. Vapor создаст для вас xcode-проект, который можно использовать для «поднятия» сервера локально на вашей машине.
import Vapor let drop = Droplet() drop.get("hello"){ request inif let name = request.data["name"]?.string{return try drop.view.make("hello", ["name": name])} return try drop.view.make("hello", ["name":"no name"])} drop.run()
Жмем cmd + B, далее запускаем проект. В консоли будет написано:
No command supplied, defaulting to serve... No preparations. Server 'default' starting at 0.0.0.0:8080
Открываем Safari (или что вы там используете как браузер). Вводим в адресную строку 0.0.0.0:8080 или localhost:8080. Видим 404 page not found. Дописываем в адресной строке:
https://localhost:8080/hello?name=enter_you_name
Видим результат в браузере:
Разумеется, если у вас есть глубокие познания в backend-разработке, вы можете написать что-то действительно хорошее :). Я лишь хотел показать, насколько swift развился за последние несколько лет, оставаясь при этом довольно простым и понятным языком.