iOS 개발자가 저지르는 가장 흔한 실수 10가지

게시 됨: 2022-03-11

App Store에서 버그가 있는 앱을 거부하는 것보다 더 나쁜 것은 무엇입니까? 받아 들였습니다. 별점 1개 리뷰가 시작되면 복구가 거의 불가능합니다. 이것은 회사의 돈과 개발자의 일자리를 앗아갑니다.

iOS는 이제 세계에서 두 번째로 큰 모바일 운영 체제입니다. 또한 85% 이상의 사용자가 최신 버전을 사용하고 있어 채택률이 매우 높습니다. 예상대로 참여도가 높은 사용자는 높은 기대치를 가지고 있습니다. 앱이나 업데이트가 완벽하지 않다면 그에 대해 듣게 될 것입니다.

iOS 개발자에 대한 수요가 계속 급증하면서 많은 엔지니어가 모바일 개발로 전환했습니다(매일 1,000개 이상의 새로운 앱이 Apple에 제출됨). 그러나 진정한 iOS 전문 지식은 기본 코딩을 훨씬 넘어 확장됩니다. 다음은 iOS 개발자가 저지르는 10가지 일반적인 실수와 이를 방지하는 방법입니다.

iOS 사용자의 85%가 최신 OS 버전을 사용합니다. 즉, 앱이나 업데이트가 완벽하기를 기대한다는 의미입니다.
트위터

흔한 실수 1번: 비동기식 프로세스를 이해하지 못함

새로운 프로그래머들 사이에서 매우 흔한 유형의 실수는 비동기 코드를 부적절하게 처리하는 것입니다. 일반적인 시나리오를 고려해 보겠습니다. 사용자가 테이블 보기로 화면을 엽니다. 일부 데이터는 서버에서 가져와서 테이블 보기에 표시됩니다. 더 공식적으로 다음과 같이 작성할 수 있습니다.

 @property (nonatomic, strong) NSArray *dataFromServer; - (void)viewDidLoad { __weak __typeof(self) weakSelf = self; [[ApiManager shared] latestDataWithCompletionBlock:^(NSArray *newData, NSError *error){ weakSelf.dataFromServer = newData; // 1 }]; [self.tableView reloadData]; // 2 } // and other data source delegate methods - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataFromServer.count; }

언뜻 보기에 모든 것이 올바르게 보입니다. 서버에서 데이터를 가져온 다음 UI를 업데이트합니다. 그러나 문제는 데이터 가져오기가 비동기식 프로세스이고 새 데이터를 즉시 반환하지 않는다는 것입니다. 즉, 새 데이터를 수신하기 전에 reloadData 가 호출됩니다. 이 실수를 수정하려면 블록 내에서 라인 #1 바로 다음으로 라인 #2를 이동해야 합니다.

 @property (nonatomic, strong) NSArray *dataFromServer; - (void)viewDidLoad { __weak __typeof(self) weakSelf = self; [[ApiManager shared] latestDataWithCompletionBlock:^(NSArray *newData, NSError *error){ weakSelf.dataFromServer = newData; // 1 [weakSelf.tableView reloadData]; // 2 }]; } // and other data source delegate methods - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataFromServer.count; }

그러나 이 코드가 여전히 예상대로 작동하지 않는 상황이 있을 수 있습니다.

일반적인 실수 2: 메인 큐가 아닌 스레드에서 UI 관련 코드 실행

이전의 일반적인 실수에서 수정된 코드 예제를 사용했지만 비동기 프로세스가 성공적으로 완료된 후에도 테이블 보기가 여전히 새 데이터로 업데이트되지 않는다고 가정해 보겠습니다. 그런 간단한 코드에 무엇이 문제가 될 수 있습니까? 이를 이해하기 위해 블록 내부에 중단점을 설정하고 이 블록이 호출되는 큐를 찾을 수 있습니다. 호출이 모든 UI 관련 코드를 수행해야 하는 기본 대기열에 있지 않기 때문에 설명된 동작이 발생할 가능성이 높습니다.

Alamofire, AFNetworking 및 Haneke와 같은 가장 인기 있는 라이브러리는 비동기 작업을 수행한 후 기본 대기열에서 completionBlock 을 호출하도록 설계되었습니다. 그러나 항상 이것에 의존할 수는 없으며 코드를 올바른 대기열로 보내는 것을 잊어버리기 쉽습니다.

모든 UI 관련 코드가 기본 대기열에 있는지 확인하려면 해당 코드를 해당 대기열로 보내는 것을 잊지 마십시오.

 dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView reloadData]; });

일반적인 실수 3: 동시성과 멀티스레딩에 대한 오해

동시성은 정말 예리한 칼에 비유할 수 있습니다. 주의하지 않거나 충분히 경험하지 않으면 쉽게 자해할 수 있지만, 적절하고 안전하게 사용하는 방법을 알면 매우 유용하고 효율적입니다.

동시성을 사용하지 않으려고 할 수는 있지만 어떤 종류의 앱을 빌드하든지 간에 동시성 없이는 할 수 없는 가능성이 매우 높습니다. 동시성은 애플리케이션에 상당한 이점을 제공할 수 있습니다. 특히:

  • 거의 모든 응용 프로그램에는 웹 서비스에 대한 호출이 있습니다(예: 일부 무거운 계산을 수행하거나 데이터베이스에서 데이터 읽기). 이러한 작업이 기본 대기열에서 수행되면 애플리케이션이 한동안 정지되어 응답하지 않게 됩니다. 또한, 너무 오래 걸리면 iOS가 앱을 완전히 종료합니다. 이러한 작업을 다른 대기열로 이동하면 앱이 정지된 것처럼 보이지 않고 작업이 수행되는 동안 사용자가 계속해서 애플리케이션을 사용할 수 있습니다.
  • 최신 iOS 기기에는 두 개 이상의 코어가 있는데, 병렬로 수행할 수 있는 작업이 순차적으로 완료될 때까지 사용자가 기다려야 하는 이유는 무엇입니까?

그러나 동시성의 이점은 복잡성과 재현하기 정말 어려운 경쟁 조건과 같은 심각한 버그를 도입할 가능성 없이는 제공되지 않습니다.

몇 가지 실제 예를 살펴보겠습니다(간단함을 위해 일부 코드는 생략됨).

사례 1

 final class SpinLock { private var lock = OS_SPINLOCK_INIT func withLock<Return>(@noescape body: () -> Return) -> Return { OSSpinLockLock(&lock) defer { OSSpinLockUnlock(&lock) } return body() } } class ThreadSafeVar<Value> { private let lock: ReadWriteLock private var _value: Value var value: Value { get { return lock.withReadLock { return _value } } set { lock.withWriteLock { _value = newValue } } } }

다중 스레드 코드:

 let counter = ThreadSafeVar<Int>(value: 0) // this code might be called from several threads counter.value += 1 if (counter.value == someValue) { // do something }

ThreadSaveVarcounter 를 래핑하고 스레드로부터 안전하게 만들기 때문에 언뜻 보기에 모든 것이 동기화되고 예상대로 작동해야 하는 것처럼 나타납니다. 불행히도 이것은 두 개의 스레드가 동시에 증분 라인에 도달할 수 있고 결과적으로 counter.value == someValue 가 결코 true가 되지 않기 때문에 사실이 아닙니다. 해결 방법으로 증가 후 값을 반환하는 ThreadSafeCounter 를 만들 수 있습니다.

 class ThreadSafeCounter { private var value: Int32 = 0 func increment() -> Int { return Int(OSAtomicIncrement32(&value)) } }

사례 2

 struct SynchronizedDataArray { private let synchronizationQueue = dispatch_queue_create("queue_name", nil) private var _data = [DataType]() var data: [DataType] { var dataInternal = [DataType]() dispatch_sync(self.synchronizationQueue) { dataInternal = self._data } return dataInternal } mutating func append(item: DataType) { appendItems([item]) } mutating func appendItems(items: [DataType]) { dispatch_barrier_sync(synchronizationQueue) { self._data += items } } }

이 경우 배열에 대한 액세스를 동기화하는 데 dispatch_barrier_sync 가 사용되었습니다. 이것은 액세스 동기화를 보장하기 위해 선호하는 패턴입니다. 불행히도 이 코드는 struct가 항목을 추가할 때마다 복사본을 만들어 매번 새로운 동기화 대기열을 갖는다는 점을 고려하지 않습니다.

여기서는 얼핏 보기에 정확해 보여도 예상대로 작동하지 않을 수 있습니다. 또한 테스트하고 디버그하는 데 많은 작업이 필요하지만 결국에는 앱 속도와 응답성을 향상시킬 수 있습니다.

일반적인 실수 4: 가변 객체의 함정을 알지 못함

Swift는 값 유형의 실수를 피하는 데 매우 유용하지만 여전히 Objective-C를 사용하는 개발자가 많이 있습니다. 가변 객체는 매우 위험하며 숨겨진 문제를 일으킬 수 있습니다. 불변 객체가 함수에서 반환되어야 한다는 것은 잘 알려진 규칙이지만 대부분의 개발자는 그 이유를 모릅니다. 다음 코드를 고려해 보겠습니다.

 // Box.h @interface Box: NSObject @property (nonatomic, readonly, strong) NSArray <Box *> *boxes; @end // Box.m @interface Box() @property (nonatomic, strong) NSMutableArray <Box *> *m_boxes; - (void)addBox:(Box *)box; @end @implementation Box - (instancetype)init { self = [super init]; if (self) { _m_boxes = [NSMutableArray array]; } return self; } - (void)addBox:(Box *)box { [self.m_boxes addObject:box]; } - (NSArray *)boxes { return self.m_boxes; } @end

NSMutableArrayNSArray 의 서브클래스이기 때문에 위의 코드는 정확합니다. 이 코드에서 무엇이 잘못될 수 있습니까?

첫 번째이자 가장 분명한 사실은 다른 개발자가 함께 와서 다음을 수행할 수 있다는 것입니다.

 NSArray<Box *> *childBoxes = [box boxes]; if ([childBoxes isKindOfClass:[NSMutableArray class]]) { // add more boxes to childBoxes }

이 코드는 수업을 엉망으로 만들 것입니다. 그러나 그 경우에는 코드 냄새이며 조각을 선택하는 것은 해당 개발자에게 달려 있습니다.

그러나 다음은 훨씬 더 나쁘고 예기치 않은 동작을 보여주는 경우입니다.

 Box *box = [[Box alloc] init]; NSArray<Box *> *childBoxes = [box boxes]; [box addBox:[[Box alloc] init]]; NSArray<Box *> *newChildBoxes = [box boxes];

여기서 예상되는 것은 [newChildBoxes count] > [childBoxes count] 이지만 그렇지 않은 경우에는 어떻게 될까요? 그러면 클래스가 이미 반환된 값을 변경하기 때문에 잘 설계되지 않은 것입니다. 불평등이 사실이 아니라고 생각되면 UIView 및 [view subviews] 를 실험해 보십시오.

운 좋게도 첫 번째 예제에서 getter를 다시 작성하여 코드를 쉽게 수정할 수 있습니다.

 - (NSArray *)boxes { return [self.m_boxes copy]; }

일반적인 실수 5: iOS NSDictionary 가 내부적으로 작동하는 방식을 이해하지 못함

사용자 정의 클래스 및 NSDictionary 로 작업한 적이 있다면 사전 키로 NSCopying 을 준수하지 않으면 클래스를 사용할 수 없다는 것을 깨달을 수 있습니다. 대부분의 개발자는 Apple이 이러한 제한을 추가한 이유를 자문해 본 적이 없습니다. Apple이 키를 복사하고 원본 개체 대신 복사본을 사용하는 이유는 무엇입니까?

이것을 이해하는 열쇠는 NSDictionary 가 내부적으로 어떻게 작동하는지 알아내는 것입니다. 기술적으로는 해시 테이블일 뿐입니다. 키에 대한 개체를 추가하는 동안 상위 수준에서 작동하는 방식을 빠르게 요약해 보겠습니다(간단함을 위해 테이블 ​​크기 조정 및 성능 최적화는 여기에서 생략됨).

1단계: hash(Key) 를 계산합니다. 2단계: 해시를 기반으로 개체를 넣을 위치를 찾습니다. 일반적으로 이것은 사전 길이와 함께 해시 값의 계수를 취하여 수행됩니다. 결과 인덱스는 키/값 쌍을 저장하는 데 사용됩니다. 3단계: 해당 위치에 객체가 없으면 연결 목록을 만들고 레코드(객체 및 키)를 저장합니다. 그렇지 않으면 목록 끝에 레코드를 추가합니다.

이제 사전에서 레코드를 가져오는 방법을 설명하겠습니다.

1단계: hash(Key) 를 계산합니다. 2단계: 해시로 키를 검색합니다. 데이터가 없으면 nil 이 반환됩니다. 3단계: 연결 목록이 있는 경우 [storedkey isEqual:Key] 까지 개체를 반복합니다.

내부에서 일어나는 일에 대한 이러한 이해를 바탕으로 두 가지 결론을 도출할 수 있습니다.

  1. 키의 해시가 변경되면 레코드를 다른 연결 목록으로 이동해야 합니다.
  2. 키는 고유해야 합니다.

간단한 클래스에서 이것을 살펴보겠습니다.

 @interface Person @property NSMutableString *name; @end @implementation Person - (BOOL)isEqual:(id)object { if (self == object) { return YES; } if (![object isKindOfClass:[Person class]]) { return NO; } return [self.name isEqualToSting:((Person *)object).name]; } - (NSUInteger)hash { return [self.name hash]; } @end

이제 NSDictionary 가 키를 복사하지 않는다고 상상해보십시오.

 NSMutableDictionary *gotCharactersRating = [[NSMutableDictionary alloc] init]; Person *p = [[Person alloc] init]; p.name = @"Job Snow"; gotCharactersRating[p] = @10;

오! 오타가 있습니다! 수정하자!

 p.name = @"Jon Snow";

우리 사전에 무슨 일이 일어나야 합니까? 이름이 변경되었으므로 이제 다른 해시가 있습니다. 이제 우리의 객체는 잘못된 위치에 있고(사전이 데이터 변경에 대해 알지 못하기 때문에 여전히 이전 해시 값을 가짐) 사전에서 데이터를 조회하는 데 사용해야 하는 해시가 명확하지 않습니다. 더 나쁜 경우가 있을 수 있습니다. 사전에 이미 5등급의 "Jon Snow"가 있다고 상상해 보십시오. 사전은 동일한 키에 대해 두 개의 다른 값으로 끝납니다.

보시다시피 NSDictionary 에 변경 가능한 키가 있으면 발생할 수 있는 많은 문제가 있습니다. 이러한 문제를 방지하는 가장 좋은 방법은 객체를 저장하기 전에 복사하고 속성을 copy 로 표시하는 것입니다. 이 연습은 또한 수업을 일관성 있게 유지하는 데 도움이 될 것입니다.

일반적인 실수 6: XIB 대신 스토리보드 사용

대부분의 새로운 iOS 개발자는 Apple의 제안을 따르고 기본적 으로 UI에 스토리보드를 사용합니다. 그러나 스토리보드를 사용하는 데에는 많은 단점과 몇 가지(논쟁의 여지가 있는) 장점만 있습니다.

스토리보드의 단점은 다음과 같습니다.

  1. 여러 팀원의 스토리보드를 수정하는 것은 정말 어렵습니다. 기술적으로 많은 스토리보드를 사용할 수 있지만 이 경우 유일한 이점은 스토리보드의 컨트롤러 간에 분리를 가능하게 하는 것입니다.
  2. 스토리보드의 컨트롤러 및 segues 이름은 문자열이므로 코드 전체에 해당 문자열을 모두 다시 입력하거나(언젠가 끊어질 것입니다) 스토리보드 상수의 방대한 목록을 유지 관리해야 합니다. SBConstants를 사용할 수 있지만 스토리보드에서 이름을 바꾸는 것은 여전히 ​​쉬운 일이 아닙니다.
  3. 스토리보드는 당신을 비모듈식 디자인으로 몰아넣습니다. 스토리보드로 작업하는 동안 뷰를 재사용할 수 있도록 만드는 인센티브는 거의 없습니다. 이는 MVP(Minimum Viable Product) 또는 빠른 UI 프로토타이핑에 적합할 수 있지만 실제 애플리케이션에서는 앱 전체에서 동일한 보기를 여러 번 사용해야 할 수 있습니다.

스토리보드(논쟁의 여지가 있음) 장점:

  1. 전체 앱 탐색을 한 눈에 볼 수 있습니다. 그러나 실제 응용 프로그램에는 서로 다른 방향으로 연결된 10개 이상의 컨트롤러가 있을 수 있습니다. 이러한 연결이 있는 스토리보드는 원사 덩어리처럼 보이며 데이터 흐름에 대한 높은 수준의 이해를 제공하지 않습니다.
  2. 정적 테이블. 이것이 내가 생각할 수있는 유일한 진정한 이점입니다. 문제는 정적 테이블의 90%가 앱 진화 중에 동적 테이블로 바뀌는 경향이 있고 동적 테이블은 XIB에서 더 쉽게 처리될 수 있다는 것입니다.

일반적인 실수 7: 혼동되는 객체와 포인터 비교

두 객체를 비교하는 동안 포인터와 객체 평등이라는 두 가지 평등을 고려할 수 있습니다.

포인터 평등은 두 포인터가 동일한 객체를 가리키는 상황입니다. Objective-C에서는 두 포인터를 비교하기 위해 == 연산자를 사용합니다. 객체 평등은 두 객체가 데이터베이스의 동일한 사용자와 같이 논리적으로 동일한 두 객체를 나타내는 상황입니다. Objective-C에서는 두 객체를 비교하기 위해 isEqual 또는 더 나은 유형별 isEqualToString , isEqualToDate 등의 연산자를 사용합니다.

다음 코드를 고려하십시오.

 NSString *a = @"a"; // 1 NSString *b = @"a"; // 2 if (a == b) { // 3 NSLog(@"%@ is equal to %@", a, b); } else { NSLog(@"%@ is NOT equal to %@", a, b); }

해당 코드를 실행할 때 콘솔에 무엇을 출력할까요? 객체와 b 모두 메모리에서 동일한 객체를 가리키고 a a is equal to b .

하지만 이제 2행을 다음과 같이 변경해 보겠습니다.

 NSString *b = [[@"a" mutableCopy] copy];

이제 이 포인터가 다른 객체를 가리키고 있기 때문에 a is NOT equal to b 이러한 객체는 동일한 값을 가지고 있어도 됩니다.

이 문제는 isEqual 또는 유형별 함수에 의존하여 피할 수 있습니다. 코드 예제에서 항상 제대로 작동하려면 3행을 다음 코드로 바꿔야 합니다.

 if ([a isEqual:b]) {

일반적인 실수 8: 하드코딩된 값 사용

하드 코딩된 값에는 두 가지 주요 문제가 있습니다.

  1. 그들이 무엇을 나타내는지 명확하지 않은 경우가 많습니다.
  2. 코드의 여러 위치에서 사용해야 하는 경우 다시 입력(또는 복사하여 붙여넣기)해야 합니다.

다음 예를 고려하십시오.

 if ([[NSDate date] timeIntervalSinceDate:self.lastAppLaunch] < 172800) { // do something } or [self.tableView registerNib:nib forCellReuseIdentifier:@"SimpleCell"]; ... [self.tableView dequeueReusableCellWithIdentifier:@"SimpleCell"];

172800은 무엇을 나타냅니까? 사용되는 이유는 무엇입니까? 이것이 2일의 초 수에 해당하는지 분명하지 않을 수 있습니다(하루에는 24 x 60 x 60 또는 86,400초가 있습니다).

하드 코딩된 값을 사용하는 대신 #define 문을 사용하여 값을 정의할 수 있습니다. 예를 들어:

 #define SECONDS_PER_DAY 86400 #define SIMPLE_CELL_IDENTIFIER @"SimpleCell"

#define 은 명명된 정의를 코드의 값으로 바꾸는 전처리기 매크로입니다. 따라서 헤더 파일에 #define 이 있고 이를 어딘가에 가져오면 해당 파일에서 정의된 값의 모든 항목도 교체됩니다.

이것은 한 가지 문제를 제외하고는 잘 작동합니다. 나머지 문제를 설명하기 위해 다음 코드를 고려하십시오.

 #define X = 3 ... CGFloat y = X / 2;

이 코드가 실행된 후 y 값이 어떻게 됩니까? 1.5라고 하면 틀렸습니다. 이 코드가 실행된 후 y 는 1(1.5 가 아님 )과 같습니다. 왜요? 대답은 #define 에 유형에 대한 정보가 없다는 것입니다. 따라서 우리의 경우 두 개의 Int 값(3과 2)을 나눕니다. 결과적으로 Int (즉, 1)가 Float 로 변환됩니다.

이는 정의에 따라 다음과 같이 입력되는 상수를 대신 사용하여 피할 수 있습니다.

 static const CGFloat X = 3; ... CGFloat y = X / 2; // y will now equal 1.5, as expected

일반적인 실수 9: Switch 문에서 기본 키워드 사용

switch 문에서 default 키워드를 사용하면 버그와 예기치 않은 동작이 발생할 수 있습니다. Objective-C에서 다음 코드를 고려하십시오.

 typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: return YES; default: return NO; } }

Swift로 작성된 동일한 코드:

 enum UserType { case Admin, Regular } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Admin: return true default: return false } }

이 코드는 의도한 대로 작동하므로 관리자만 다른 레코드를 변경할 수 있습니다. 그러나 레코드를 편집할 수 있어야 하는 다른 사용자 유형인 "관리자"를 추가하면 어떻게 될까요? 이 switch 문을 업데이트하는 것을 잊어버리면 코드가 컴파일되지만 예상대로 작동하지 않습니다. 그러나 개발자가 처음부터 기본 키워드 대신 열거형 값을 사용한 경우 컴파일 시간에 간과가 식별되고 테스트 또는 프로덕션으로 이동하기 전에 수정될 수 있습니다. 다음은 Objective-C에서 이를 처리하는 좋은 방법입니다.

 typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular, UserTypeManager }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: case UserTypeManager: return YES; case UserTypeRegular: return NO; } }

Swift로 작성된 동일한 코드:

 enum UserType { case Admin, Regular, Manager } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Manager: fallthrough case .Admin: return true case .Regular: return false } }

일반적인 실수 10: 로깅에 NSLog 사용

많은 iOS 개발자는 로깅을 위해 앱에서 NSLog 를 사용하지만 대부분의 경우 이것은 끔찍한 실수입니다. NSLog 기능 설명에 대한 Apple 문서를 확인하면 매우 간단하다는 것을 알 수 있습니다.

 void NSLog(NSString *format, ...);

무엇이 잘못될 수 있습니까? 사실, 아무것도. 그러나 장치를 Xcode 구성 도우미에 연결하면 거기에 모든 디버그 메시지가 표시됩니다. 이러한 이유 하나만으로 NSLog 를 로깅에 사용 해서는 안 됩니다. 원하지 않는 내부 데이터를 표시하기 쉽고 전문가가 아닌 것처럼 보입니다.

더 나은 접근 방식은 NSLog를 구성 가능한 NSLogs 또는 다른 로깅 프레임워크로 교체하는 것입니다.

마무리

iOS는 매우 강력하고 빠르게 발전하는 플랫폼입니다. Apple은 iOS 자체에 새로운 하드웨어와 기능을 도입하는 동시에 Swift 언어를 지속적으로 확장하기 위해 엄청난 노력을 기울이고 있습니다.

Objective-C 및 Swift 기술을 향상시키면 훌륭한 iOS 개발자가 될 것이며 최첨단 기술을 사용하여 도전적인 프로젝트에서 작업할 수 있는 기회를 제공할 것입니다.

관련: iOS 개발자 가이드: Objective-C에서 Swift 학습까지