안녕하세요!! 오늘은 DequeueReusableCell deep dive를 해보려고 합니다. 친한 형과 대화하다가 저도 궁금했던,, 추가적인 개념을 얘기하면서 배웠고 포스트로 정리하려고 합니다. MVVM 패턴에 대한 의견 공유 중 "cell's model은 vm이 가질 때" 에 관한 얘기가 나왔습니다. cf(참고). 개인적으로 cell 또한 view이고 결국 view는 model이 vm을 통해서 하위 레이어가 관리되야 한다는 생각이 있습니다.
핑크 화살표로 읽어 가시면 됩니다!!
DequeueReusableCell을 DeepDive 하게 된 계기
많은 내용이 오갔고 생략됬는데 MVVM 패턴에 대한 제 생각은 cell's vm에서 cell's model에 대한 처리를 해주면 좋겠다는 생각이었습니다(서버의 데이터 처리 등 + 갱신된 모델 또한 cell's vm에서 업데이트하고 cell한테 이벤트 보내고 등등..) 저는 Cell의 viewModel은 cell안에서 "cellForRowAt호출 시점에 반드시+_+ 만들어진다" 생각했고, clean architecture with mvvm을 공부중인 석현이형은 해당 example에 vm은 mainVM에서 CellVM들을 전부 생성한 예시를 기준으로 cell의 뷰모델 생성 시점을 생각하고 있었습니다. "mainVM이 모델도 갖고 있겠다 +_+ cellVM도 만들어 관리해볼까? - 클린아키텍쳐 중 mainVM에서 cellVM까지 생성한 코드 일부 구현이 이렇게 되어있습니다." 서로가 CellVM을 어디서 생성하는지 생성 시점 차이가 있었네요. 이부분은 생략하고 ,,
주요 내용은 오늘 다뤄볼 내용인 dequeueReusableCell의 재사용 메커니즘은 cell을 어떻게 관리하는지부터 시작되었습니다. 이로부터 시작되서 ~ 라인의 MessageViewModel은 왜 protocol이 아닌 class를 선택했는지에 대한 이야기까지.. 약 6시간의 대화를 했다는 ㅋㅋ,, 오늘 참 많이 배웠네요. (형 땡큐) 막연하게 재사용 큐에 의해 재사용 된다는 것은 알았는데 오늘 확실하게 알았습니다.
위 카톡에서 제가 말한 결론은 제가 정확히 틀린 것 같습니다. 제 계획은 Cell 내부에서 cellVM을 직접 생성해서 cellVM의 model을 업데이트 후 cell에서 발생되는 user interaction은 전부 cell's vm에서 처리를 하고 model 업데이트 -> cell UI render를 할 생각이었습니다. 이는 결국 재사용 셀에 의해... cellVM의 model관리가 어려워집니다. 결국 cellVM의 모델은 모델들을 전부 갖고 있는, numberOfRowsInSection에서 반환을 하는 그 객체가 관리를 해야 cellVM을 다루기도 쉬워진다는 생각을 하게 되었습니다. cellVM에서 내부적으로 서버와 통신해 model 객체를 변경한다고 해도, model 객체를 참조타입으로 선언한다 해도 내부 Int, Double 등은 값 타입이기에 '복사'형식으로 전달됩니다.
글을 작성하면서 느낀것은 mainVM 내부에서 cell's vm을 전부 생성하는것은 좋지 않다?는 것입니다. 사용하지 않는 다량의 cellVM들이 메모리에 로드된 후 유지되기 때문입니다. 만약 각각의 cell마다 담당해야 하는 vm이 달라야한다면!!! vm내부의 데이터가 달라야 한다면 prepareForReuse에서 model이나 vm을 nil처리한 후에 값을 새로 받으면 됩니다. 이럴 경우 초기 화면에 보여질 cell개수 + 여분의 개수만큼 vm인스턴스들이 할당 , 해제를 반복할 것입니다.
DequeueReusalbeCell and Reusable queue : ]
Section이 한 개일 경우를 예시로 들겠습니다. TableView의 numberOfRowsInSection 메서드에 100개의 count가 반환되어도 재사용 큐를 사용한다면 초기 화면에 보여질 cell 개수만큼 cell을 생성합니다( + a? 여유분으로 ). 그 이후 아래로 스크롤이 되거나 위로 스크롤 될 경우 tableView의 새로운 데이터가 화면에 보여질 때는 초기에 생성했던(초기에 화면에 보였던 cell)들이 화면 밖으로 사라집니다. 이때 화면 밖으로 벗어난 cell들을 재사용합니다.
어떻게 재사용하냐면!!!
위 영상처럼 동작합니다. numberOfRowsInSection은 100을 반환합니다. 초기 화면에 보이는 Cell은 17개입니다. 17개만큼의 cell instance가 cell's init함수(지정 생성자)를 통해 호출됩니다. 그 이후 스크롤이 되어 화면에 사라질 cell은 인스턴스 할당 해제가 되지 않고, reusable Queue에 enqueue(삽입) 됩니다( cell 인스턴스 유지). 그리고 새로 보여져야 할 데이터는 cell의 인스턴스가 필요한데 이는 재사용 큐에 저장되었던 cell이 dequeue(반환) 해서 재사용합니다.
Reusable queue에 꺼내지는 cell에 대한 원리를 파악하기 쉬운 예
어떻게 알 수 있을까요? 그것은 cell이 참조하는 힙 영역의 주소를 보면 될 것입니다. (JK님의 dump(with:) 코드를 사용했습니다. 타입별 메모리 분석 관련 글) 에서 참조 타입의 객체가 가리키는 주소를 알 수 있는 코드를 사용했습니다.
첫 indexPath.row == 0일 때, indexPath.row == 1일 때 생성된 주소를 비교해 본 결과 재사용이 됩니다 :) 대박 신기하네요. 네 이제 진짜 알았습니다. 초기에 화면에 보여지기 위한 cell + 여유분 몇 개?가 생성됬다는 것을요 +_+
아! 반대로 cell을 재사용하지 않는다면 어떻게 될까요?
Reusalbe queue를 사용하지 않는다면? 뭐가 어떻게 달라질까요??
tableView(_:cellForRowAt:)메서드는 지금 화면에 보일 cell에 대한 데이터를 tableView에게 전달하기 위해 dataSource protocol 함수인 tableView(_:cellForRowAt:)의 반환(이때 cell은 생성 or 재사용 큐에서 꺼내져오는 cell)을 통해 tableView에게 전달할 때 호출해주는 함수입니다.
예를들어 10000개의 데이터를 tableView로 보여준다고 가정하겠습니다. 이때 재사용 큐를 쓰지 않는다면, cell 인스턴스 10000개를 전부 만들어 둬야 합니다(물론 페이징을 사용할 수 있겠지만 결국 페이징에 의해 조금씩 생성되어 추가되는 cell 인스턴스들은 메모리에 누적되어 로드 후 유지될 것이고 메모리 사용량이 높아질 것입니다.) 그렇지 않으면 이 10000개는 메모리에 로드되어 할당 해제되기 전까지 system resource를 사용하게 됩니다. 인스턴스들이 전부 해제된다면? 메모리 사용량이 급격히 낮아질 것입니다.
기본적으로 프로그램 실행 시 10000개의 Cell을 메모리에 로드했기에(10000개의 cell 인스턴스 바로 생성) 왼쪽 메뉴바의 Memory 사용량이 대략 100MB을 유지하다가 cellForRowAt의 indexPath.row > 1000일 때!!!!! 10000개의 인스턴스를 전부 메모리에서 해제한 후 3개만 추가했습니다. 메모리 사용량이 50MB로 뚝 감소했습니다. 장점은 Cell이 각각의 주소를 갖고 있다는 것? + 스크롤할 때 좀 더 빠르다는 것?! 단점은 인스턴스 10000개를 갖고 있기에 메모리 기본 사용량이 높습니다. (10000개의 인스턴스는 메모리에 로드되었기에 그만큼 자원을 사용하고 있기 때문입니다.)
재사용 큐를 사용한다면 어떤 장, 단점이 있고 어떤 문제가 발생될 까요?
이번엔 dequeue를 사용한 경우입니다. 위에서 설명했지만, 이 경우엔 초기 화면에 보이는 cell 인스턴스 개 수 + 추가 몇 개?를 바탕으로 reusable queue에 넣어 초기에 만들었던 cell들은 할당 해제하지 않고 추후 스크롤을 통해 화면에 보여져야 할 cell의 인스턴스로 재사용 되게 됩니다.(이전에 메모리에 로드되었던 화면에 보이는 cell개수 + 몇 개가 계속해서 사용됩니다.)
그 이유는 특정 UITableViewCell 메타타입! 타입이 일치하는 Cell들은 UI layout 또 거의 비슷하고 데이터만 다를 테니까요!! 그럼에도 재사용 큐에 지정된 Cell이 꺼내질 때는 이전에 할당됬던 데이터와 UI layout등을 사용하려고 할 것입니다.
(물론 타입이 일치하는 Cell이어도 어느 데이터 형식인지에 따라 다르게 보여줘야 하는 경우 정말 조심해야 합니다.)
같은 CollectionViewCell 타입인데 다르게 보여져야 하는 경우
같은 특정한 Cell 타입을 인스턴스로 쓰는데, 같은 Cell인데 어느 경우에 따라 데이터가 다르게 보여질 수 있을까요?
제가 생각하는 예로 최근에 만들었던 화면인데 사용자가 작성한 글을 보여주는 페이지입니다. 사용자가 글 작성하면서 올린 사진 개수에 따라서 1~5+가지 이미지를 다른 UI 형식으로 보여줘야 합니다.
지금 생각해보니 단순히 화면 구현은 어렵지 않았지만 캐싱 처리랑.. 재사용 큐에서 cell이 꺼내질 때 초기화 + 캐싱처리를 잘 해야겠다는 생각이 들었습니다. 저 화면에서 사실 3개의 컬랙션 뷰를 썼는데 큰일이네요. 실제 서버에서 데이터를 받아오면 어떤 일이 발생될지 모르겠습니다ㅠㅠ.
소켓 통신 프로젝트할 때도 mock 데이터로 채팅 기능 구현할 땐 좋았는데 실제로 서버에서 데이터를 받아오니 제가 모든 화면을 구현했지만 다른 누군가 바꿔둔 것처럼 cell이 이상하게 보여진다던가.. 실시간 통신 -> 예상치못한 변수들을 맞이했습니다. 그 중 대부분이 기능들이 재사용 큐에서 cell이 꺼내져 오는 것과 관련이 있네요. 예전부터 개발하면서 땔래야 땔 수 없었던 dequeuReusableCell관련 이슈. 지금은 재사용 큐에 대한 개념을 많이 익힌 것 같습니다. 저번 프로젝트 할 때는 스크롤 하다가 왜 반대로 갈 때 이전의 celldl 나왔는지 해킹당한줄 알았는데,,
이전 실험은 10000개가 한번에 메모리에 로드되었지만 위 코드의 경우에서 재사용 큐를 쓴다면, 초기에 생성됬던 cell들을 만들지 않고 대략 20개?정도의 Cell인스턴스를 매번 재 활용하게 됩니다. Cell이 재활용된다는건 Cell안에 정의된 vm, UIComponent 등이 할당 해제 되지 않고 유지될 수 있다는 것입니다.
이 실험에서 Cell이 할당 될 경우는 거의? 약 20개!! 위에 실험과 달리 10000개의 인스턴스를 메모리에 유지하고 있지 않아도 됩니다. 요기에 페이징 기법을 사용해 dataSource를 추가했을 때도 초기 생성된 cell이 거의 재사용됩니다,, 근데 빨리 스크롤 하니까 아주 소수의 한 두개는 Cell인스턴스가 새로 생성되었습니다. (재사용 큐는 결국 화면에 보이는 Cell들만 메모리를 유지 == 최적화된 기법)
dequeueReusableCell(withIdentifier:for:)메서드란?
class ViewController: UIViewController {
...
var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView = .init(frame: view.bounds)
view.addSubview(tableView)
// 1.
tableView.register(Cell.self, forCellReuseIdentifier: Cell.id)
tableView.dataSource = self
}
}
extension ViewController: UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// 2.
guard let cell = tableView.dequeueReusableCell(withIdentifier: Cell.id, for: indexPath) as? Cell else {
return .init(style: .default, reuseIdentifier: Cell.id)
}
cell.configure(with: "\(dataSource[indexPath.row])")
return cell
}
}
1. 테이블 뷰에 보여질 UITableViewCell 타입을 register로 등록할 때, 사용된 id를 통해 어느 cell 인스턴스가 tableView의 cell로 보여질 것인지, UITableViewCell 타입의 타입인 메타티입(Cell.self)과 그 메타타입을 식별하기 위한 identifier를 등록합니다.
2. 내가 등록한 특정 cell이 재사용큐에 있다면 꺼내오고(using dequeueReusableCell(withIdentifier:for:)) , 재사용 큐에 dequeue된 cell이 없다면??!!(맨 처음에 빈 화면에 tableView의 영역만큼 cell이 채워져서 보여져야 하는 경우!!!) cell's init?(coder:) or init(frame:) 메서드를 통해 인스턴스를 생성해서 반환합니다.
이때 indexPath를 통해 어느 영역의 item 인지에 대한 cell을 재사용 큐로부터 반환합니다.
재사용 큐와 재사용 하지 않을 경우 완벽 차이 파해치기!! with simple project :)
아직도 재사용 큐에서 재사용 되는지 이해가 안 갈 수 있습니다. 이 코드를 보면 더 확실해집니다... +_+ (재사용 큐를 사용할 때, 사용하지 않을 때 코드)
이 프로젝트 주요 실험은 cell안에 있는 vm이 옵셔널 타입이고 cell.configure(with:) 함수를 호출할 때 vm이 초기화 되지 않았는지 조건 체크 후 with 매개변수의 값을 통해 vm을 초기화 한 후에 label의 값을 업데이트 하는 로직의 코드입니다.( vm 중복 생성 방지 )
여기서 중요한 것은 cell.configure(with:)함수는 tableView(_:cellForRowAt:)에서 호출되고, 중요한 것은 cell 안에서 !!! vm의 인스턴스가 초기화 되지 않았다면 configure(with:)함수를 통해 초기화 해주는 것입니다.
문자 데이터는 10000개 입니다. 재사용 큐를 사용하지 않는 경우는 페이징을 사용하던 결국에 마지막까지 스크롤하면 10000개의 Cell을 소유하게 됩니다. 그래서 사전에 그냥 10000개의 Cell인스턴스를 만들었습니다.
재사용 큐를 사용하는 경우에는 tableView에 지정된, 특정 Cell을 reusableQueue로 부터 받아오거나 새로 생성해서 반환받습니다 :)
두 초기화 로직은 결국 아래로 스크롤 할 때(새로운 Cell이 화면에 보여질 때!!!!!!!!!) cell.configure(with:)를 호출해서 옵셔널 타입인 vm인스턴스를 초기화 해서!! vm이 직접 cell의 내부 UI component(여기서는 레이블)의 데이터를 갱신하는 것입니다.
뷰 컨트롤러에서 viewDidLoad에서 makeTableView(with:)함수를 통해 재사용 큐를 사용할 경우, 재사용 큐를 사용하지 않을 경우 각각의 경우 실행할 수 있도록 코드를 구현했습니다.
모든 Cell의 인스턴스를 생성하고 CellForRowAt에서 직접 생성한 10000개 중 각각의 cell에 대해서 cell's vm의 인스턴스는 nil이기에 configure(with:)가 호출됩니다.
하지만 재사용 큐를 사용할 경우 초기에 화면에 보여지는 Cell들만 초기화가 되고, 이때 vm은 nil. 이후 이 초기 화면에 보이는 cell개수 만큼만 CellForRowAt이 호출되고, configure(with:)에서 vm이 초기화 됩니다. 그러나 그 이후에 스크롤 할 때는 reusable queue에 저장된 cell이 반환되기에, 이 cell도그렇고 vm도 인스턴스가 할당되어있어서 configure(with:)함수가 호출되지 않습니다.
재사용 큐를 사용할 때 장점은 초기 화면에 보여져야 할 Cell들 개수만큼 인스턴스를 init(frame:)등의 지정 생성자를 통해 생성합니다. 위에서 주소값을 보셨겠지만 이렇게 생성된 재사용 셀들은 화면에 사라질 때 재사용 큐에 저장됬다가, 아래로 또는 위로 스크롤 될 때 재사용 큐에서 꺼내져옵니다.
그래서 위 문제를 해결하기 위해서는 재사용될 때 cell이미 생성된 cell의 인스턴스 중 초기화를 할 프로퍼티나 속성 등이 있는가? 있다면 prepareForReuse(). 재사용 큐에서 Cell이 꺼내지기 직전에 호출되는 메서드를 통해 nil처리를 하던가 적절한 초기 디폴트 값을 넣어주어야 합니다. 또는 CellForRowAt시점에 그냥 새로 관련된 프로퍼티들을 할당한다던가,, 이 두 경우가 제가 생각한 해결 방법이었습니다.
이전에 겪은 후 해결했거나, 이제는 해결할 수 있는 문제들!!
크으,, 위 경우처럼 재사용 큐를 통해 화면을 전환할 때 설계하지 않은 화면이 갑자기 나온다던가,, (위 경우에 대한 문제 발생 원인와 해결 방법 정리 글)
희안하게 저 혼자 했던 프로젝트에서 Feed post Cell's vm의 바인딩을 cell한테 단 한 번 해줬는데. 그리고 viewWillAppear시점에는 아무것도 안했습니다. 이때 다른 화면으로 갔다가 다시 특정 화면으로 되돌아 올 때 cell에 바인딩이 2배로 늘어난 다던가,, (이 경우 에 대한 문제 발생 원인과 해결 방법 정리 글)
이렇게 재사용 큐의 메모리 효율성과 + 위험성을 알아봤습니다. 중요한 것은 큐에서 꺼내지기 전에 이전에 할당된 데이터 기반인 Cell의 인스턴스에서 초기화 해야 할 것이 있는지, 값을 바꿔야 할 것이 있는지 등을 잘 확인해야 겠다는 생각이 들었습니다.
https://github.com/SHcommit/SoTalk/issues/2
https://github.com/SHcommit/UIKitDeepDive/tree/master/Example-DequeueReusableCell-Problem/Source
https://medium.com/ios-seminar/why-we-use-dequeuereusablecellwithidentifier-ce7fd97cde8e
'iOS > Deep dive!!!' 카테고리의 다른 글
[iOS] 컬랙션 뷰 Flow -> Compositional layout으로 리펙터링하기 #1 (0) | 2023.10.01 |
---|---|
[iOS] super 클래스에서 발생되는 delegate! sub class의 delegate로 한번에 처리하기 | Base view #1 (0) | 2023.10.01 |
[iOS] UnitTest?! 테스트 코드를 짜면 좋은 이유 | Protocol을 통한 의존성 주입+역전 개념 완전 뿌수기 (0) | 2023.07.13 |
[Test] xcode 14.3에서 code coverage 설정하는 방법 (0) | 2023.06.29 |