GYPHY ๊ฒ์ API๋ฅผ ํ์ฉํ iOS GIF ์ด๋ฏธ์ง ๊ฒ์ ์ดํ๋ฆฌ์ผ์ด์
- ์ต์ ํ๊ฒ : iOS 14.0
- CleanArchitecture + MVVM-C ํจํด ์ ์ฉ
- CoreData ํ๋ ์์ํฌ ์ฌ์ฉ์ผ๋ก ์ฆ๊ฒจ์ฐพ๊ธฐ ๋ชฉ๋ก ์ ์ง
- Storyboard๋ฅผ ํ์ฉํ์ง ์๊ณ ์ฝ๋๋ก๋ง UI ๊ตฌ์ฑ
- Pagination ๊ตฌํ
- ์ด๋ฏธ์ง ๊ฒ์ ๋ทฐ
- ์นดํ ๊ณ ๋ฆฌ๋ณ ๊ฒ์ ๊ธฐ๋ฅ
- ํ์ด์ง๋ค์ด์ - prefetching ๋ฐฉ์
- ๋ํ
์ผ ๋ทฐ
- ์ฆ๊ฒจ์ฐพ๊ธฐ ์ถ๊ฐ/์ ๊ฑฐ
- ์กํฐ๋นํฐ ์ธ๋์ผ์ดํฐ
- ๊ธฐ๊ธฐ ๋ด๋ถ ํ์ผ ์ ์ฅ
- ์คํฌ๋ฉ ๋ทฐ
- ์คํฌ๋ฉ ๋ชฉ๋ก ๊ด๋ฆฌ
- ์คํฌ๋ฉ ๋ฐ์ดํฐ ์ ๊ฑฐ
- Masonry layout
Swift, MVVM+C, CI/CD, Unit Test, CoreData, SnapKit, Alamofire, Toast-swift, RxCocoa, RxSwift, RxTest
๊ธฐ์กด ํ๋ก์ ํธ์์๋ ์ฝ๋๋ค์ดํฐ๋ง ๊ตฌ์ฑํ์ฌ ํ์ฉ์ ํ์์ผ๋, ์ด๋ฅผ ๋ผ์ฐํฐ๋ ๋ถ๋ฆฌํ์ฌ ์ญํ ์ ๋ถ๋ดํด๋ณด๊ณ ์ถ์์ต๋๋ค. ์ฝ๋๋ค์ดํฐ๋ ViewController ์ธ์คํด์ค๋ฅผ ๊ตฌ์ฑํ๊ณ present ๋๋ ์์(flow)๋ฅผ ๊ฒฐ์ , ๋ผ์ฐํฐ๋ ์ค์ง์ ์ธ present / dismiss ์คํํ๋ฉด์ ํ๋ฉด ์ ํ์ ๊ตฌ์ฑ์ ๋ชจ๋๋ณ๋ก ๋ถ๋ฆฌํ์ฌ ๊ฐ์์ ์ฑ ์์ ๊ฐ๋ฅํ๋๋ก ๊ตฌ์ฑํ๊ณ ์ถ์์ต๋๋ค.
ํ์ง๋ง, UITabBar์ ๊ฐ์ ์ ํ ๊ด๋ จ Basic Frame์ด ๋ผ์ด๋ค ๋ณด๋ ๋ถ๋ฆฌํ๋ ๊ฒ ์ด๋ ต๋ค๋ ํ๋จ์ด ๋ค์๊ณ , ์ฌ๊ธฐ์ ๋ํ ํด๊ฒฐ์ฑ ์ผ๋ก RIBs์ ๋ํด์ ๋จ์ด๋ฅผ ์ป์ ์ ์์์ต๋๋ค. ๋ผ์ฐํฐ์ ๊ธฐ๋ฐํ์ฌ ๋ทฐ๋ฅผ ๋ถ์๋ค ๋ผ๋ ์ํคํ ์ณ์ ๋๋ถ์ด ์ํ ๊ด๋ฆฌ์ ์์ด์๋ ReactorKit์ ๊ธฐ๋ฐํ์ฌ ์ด๋ฅผ ์ฌ๊ตฌ์ฑํ ๊ณํ์ ๋๋ค.
๊ตฌ์ฑํ๋ฉด์ ๋๋ ์ ์ ํ์ฌ์ ๊ฐ์ด UITabBar๋ง์ผ๋ก ๊ตฌ์ฑ๋ ๋จ์ผํ ์ํฉ์์๋ modal ๋ฑ ๋ถ๋ชจ ์ฝ๋๋ค์ดํฐ์ ์์ ์ฝ๋๋ค์ดํฐ์ ๊ด๊ณ ๋ฑ์ ๊ตฌ์ํ์ง ์์๋ ๋์๊ธฐ์ ๋น๊ต์ ๋จ์ํ ๋ชจ์์ผ๋ก ๊ตฌ์ฑ๋๊ฒ ๋์์ง๋ง, ๋ง์ฝ ์ด๋ฅผ ํ์ฅํด์ ํ๋ฉด ์ ํ flow๊ฐ ๋ณต์กํด์ง๋ค๋ฉด ํด๋น ๋์์ธ ํจํด์ ํ์ฉํ๋ ๊ฒ์ด ๋์ฑ ๋ ์ข์ ๋ฐฉ์์ผ๋ก ์ ์ง๋ณด์ ํ ์ ์๋ ๋ฐฉ๋ฒ์ด๋ผ ์๊ฐํฉ๋๋ค.
GIF์ ์์ง์ด๋ ํ๋ ์ ๊ธฐ๋ฅ ๊ตฌํ์ ์ํด git repo์ ๊ณต๊ฐ๋ ์ํ ์์ค์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ ์ฉํ์ต๋๋ค. ๊ทธ๋ฌ๋, ํด๋น ์ฝ๋์์๋ ๋ฐ์ดํฐ ์บ์ฑ์ ๋ฐ๋ก ํด์ฃผ์ง ์์์ GIF ๋ฐ์ดํฐ ๋ก๋ฉ์ด ๋๋ ๋ฐ์ดํฐ๋ ๋์ค์ ๋ค์ ํ์ธํ๋ฉด ๋ก๋ฉ์ ๋ ๊ธฐ๋ค๋ ค์ผํ์ต๋๋ค. ๋ค์ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์์ค๋ ๊ณผ์ ์ ์์ด์ ๋ฉ๋ชจ๋ฆฌ์ ์ฌ์ฉ๊ณผ ํจ๊ป ์คํฌ๋กค ๋ฒ๋ฒ ์๋ ์๋นํ ์ฌํ์ต๋๋ค. ๋ฌธ์ ํด๊ฒฐ์ ์ํด ํด๋น ์์ค์ฝ๋์ ์ด๋ฏธ์ง ์บ์ฑ ๊ธฐ๋ฅ์ ๋ฃ์ด์คฌ์ต๋๋ค. ์ฑ๊ธํด์ผ๋ก ๊ตฌ์ฑํ ์ด๋ฏธ์ง ์บ์ฑ ๋๋ถ์ ์ต์ด์ ์๋ก์ด GIF ๋ฐ์ดํฐ๋ฅผ ํธ์ถํ ๋๋ง ์คํฌ๋กค์ด ๋๋ ์ด๋๊ณ ์ด ํ์๋ ๋๋ ์ด ํ์์ด ์์ด์ก์ต๋๋ค.
final class ImageCacheManager {
static let shared = NSCache<NSString, UIImage>()
private init() {}
}
์ฝ๋ ์ ๋ทฐ์ GIF ์ด๋ฏธ์ง๋ฅผ ๊ตฌ์ฑํ๊ณ ์ ์ ๊ทธ๋ฆฌ๋ ๊ณผ์ ์ ๋ณธ๋๋ ๋ชจ๋ main ์ค๋ ๋์์ ์ฒ๋ฆฌํ์์ต๋๋ค. ํ์ง๋ง, ๊ทธ๋ฆฌ๋ ๋ฐ์ ํ ๊ฐ์ง ์ค๋ ๋์์ ๋ชจ๋ ์ผ์ ์ํํ๋ค ๋ณด๋ ๋๋ ์ด๊ฐ ๋ฐ์ํ์์ต๋๋ค. ๋ฐ๋ผ์, ์ถ๊ฐ์ ์ผ๋ก setup์์ ๋ฉํฐ์ฐ๋ ๋ฉ ์ฒ๋ฆฌ์ ํด์ค์ผ๋ก์จ ์คํฌ๋กค ๋๋ ์ด๋ฅผ ๊ฐ์ ํ์ต๋๋ค.
func setup(gifItem: GIFItem, indexPath: Int) {
cellView.indicatorAction(bool: true)
DispatchQueue.global().async { [weak self] in
let image = UIImage.gifImageWithURL(gifItem.images.preview.url)
DispatchQueue.main.async {
self?.cellView.imageView.image = image
self?.cellView.indicatorAction(bool: false)
}
}
}
global(background) ์ค๋ ๋์์ gifImageWithURL() ๋ฉ์๋๋ฅผ ํตํด ์ฐ์ ์ ์ผ๋ก ์บ์ฑ ์ฌ๋ถ๋ฅผ ํ์ธํ๊ณ ํด๋น URL์ ๋ํ animatedImage ์ด๋ฏธ์ง๋ฅผ ํ๋ ์์ ๋ฐ๋ผ array ํํ๋ก ๊ตฌ์ฑํ๋ ์์ ์ ์ํํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ main ์ค๋ ๋๋ก ํด๋น view๋ฅผ ๊ทธ๋ฆฌ๋ฉด์ ์์ ์ ์ผ๋ก ๋ถ๋ฌ์ฌ ์ ์๊ฒ ํ์์ต๋๋ค.
์ฝ๋๋ SwiftGif ๋ ํฌ๋ฅผ ์ฐธ์กฐํ์ฌ ๊ตฌ์ฑํ์์ต๋๋ค.
protocol ViewModel {
associatedtype Input
associatedtype Output
func transform(input: Input) -> Output
var disposeBag: DisposeBag { get set }
}
ํจํด์ ๊ฐ์ฅ ํฐ ์งํฅ์ ์ ๋ชจ๋ ์ฌ์ฉ์ ์ด๋ฒคํธ๋ฅผ ViewModel๋ก ๋๊ฒจ ๋น์ฆ๋์ค ๋ก์ง์ ViewModel์์๋ง ์ฒ๋ฆฌํ๋๋ก ํ๋ ๊ฒ์ด๊ธฐ ๋๋ฌธ์ ๋ฒํผ์ ํญ ์ด๋ฒคํธ, ํ ์คํธํ๋์ ์ ๋ ฅ ์ด๋ฒคํธ ๋ฑ์ ์ ๋ถ Input์ ์ ์ํ๊ณ View๋ก ๋๊ฒจ์ค ๋ฐ์ดํฐ๋ค์ Output์ ์ ์ํ๋ ๊ฑธ ๋ชฉํ๋ก ํฉ๋๋ค.
๊ธฐ์กด์ rx ๊ธฐ๋ฐ์ผ๋ก protocol์ ๊ตฌ์ฑํ๊ณ convertํ๋ ๋ฉ์๋๋ฅผ ๊ตฌํํ์๋๋ฐ ์์ ์ ํ๋ฉด์ ๊ธฐ์กด rx๋ก ๋ํ๋์ด ๋ฐ์ธ๋ฉ๋ ๊ฒ์ด ์๋, BehaviorSubject ๋ฑ์ผ๋ก ์ด๋ฃจ์ด์ง ๋ณ๋์ ์คํธ๋ฆผ ํ๋กํผํฐ๋ค์ด viewModel ๋ด ์กด์ฌํ๊ฒ ๋ฉ๋๋ค. ๋ํ์ ์ถ๊ฐ์ ์ผ๋ก ๊ตฌ์ฑํ์ฌ Reactiveํ๊ฒ ์ฒ๋ฆฌํ ๊ฒ์ธ์ง, ์๋๋ฉด ReactorKit๊ณผ ๊ฐ์ ์ํ ๊ด๋ฆฌ ์๋ํํฐ๋ฅผ ํ์ฉํ์ฌ ๋ค์ ํ๋ก์ ํธ์์ ๊ฐ์ ํ๊ณ ์ ํฉ๋๋ค.
๐๐ป ReactorKit์ผ๋ก ๋จ๋ฐฉํฅ ๋ฐ์ํ ์ฑ ๋ง๋ค๊ธฐ
@objc(GIFItem_CoreData)
public class GIFItem_CoreData: NSManagedObject, Codable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
do {
try container.encode(id ?? "", forKey: .id)
try container.encode(type ?? "", forKey: .type)
try container.encode(webPageURL ?? "", forKey: .webPageURL)
try container.encode(title ?? "", forKey: .title)
try container.encode(images, forKey: .images)
try container.encode(user, forKey: .user)
try container.encode(isFavorite, forKey: .isFavorite)
} catch {
print("error")
}
}
required convenience public init(from decoder: Decoder) throws {
guard let contextUserInfoKey = CodingUserInfoKey.context,
let managedObjectContext = decoder.userInfo[contextUserInfoKey] as? NSManagedObjectContext,
let entity = NSEntityDescription.entity(forEntityName: "GIFItem_CoreData", in: managedObjectContext)
else {
fatalError("decode failure")
}
self.init(entity: entity, insertInto: managedObjectContext)
let values = try decoder.container(keyedBy: CodingKeys.self)
do {
type = try values.decode(String.self, forKey: .type)
id = try values.decode(String.self, forKey: .id)
webPageURL = try values.decode(String.self, forKey: .webPageURL)
title = try values.decode(String.self, forKey: .title)
images = try values.decode(GIFCategory_CoreData.self, forKey: .images)
user = try values.decode(UserData_CoreData.self, forKey: .user)
isFavorite = try values.decode(Bool.self, forKey: .isFavorite)
} catch {
print ("error")
}
}
func convertToGIFItem() -> GIFItem {
return GIFItem(
type: self.type!,
id: self.id!,
webPageURL: self.webPageURL!,
title: self.title!,
images: GIFCategory(
original: GIFSize(
height: (self.images?.original?.height)!,
width: (self.images?.original?.width)!,
size: (self.images?.original?.size)!,
url: (self.images?.original?.url)!
),
preview: GIFSize(
height: (self.images?.preview?.height)!,
width: (self.images?.preview?.width)!,
size: (self.images?.preview?.size)!,
url: (self.images?.preview?.url)!
)
),
user: UserData(
avatarURL: (self.user?.avatarURL)!,
name: (self.user?.name)!
),
isFavorite: self.isFavorite
)
}
enum CodingKeys: String, CodingKey {
case type = "type"
case id = "id"
case webPageURL = "webPageURL"
case title = "title"
case images = "images"
case user = "user"
case isFavorite = "isFavorite"
}
}
extension GIFItem_CoreData {
@nonobjc public class func fetchRequest() -> NSFetchRequest<GIFItem_CoreData> {
return NSFetchRequest<GIFItem_CoreData>(entityName: "GIFItem_CoreData")
}
@NSManaged public var type: String?
@NSManaged public var id: String?
@NSManaged public var isFavorite: Bool
@NSManaged public var webPageURL: String?
@NSManaged public var title: String?
@NSManaged public var user: UserData_CoreData?
@NSManaged public var images: GIFCategory_CoreData?
}
extension GIFItem_CoreData : Identifiable {}
๊ธฐ์กด์ CoreData๋ฅผ ํ์ฉํ๋ฉด์ ๊ฐ์ฒด๋ฅผ ๋จ์ํ NSManagedObject๋ก ์ ํํ์ฌ ์ ์ฅํ๋ ์ฉ๋๋ก ํ์ฉํ์์ต๋๋ค. ์ด๋ฒ์๋ ๋์๊ฐ ๊น๊ฒ ๊ณ์ธตํ๋ JSON ๊ฐ์ฒด๋ฅผ ํ์ฑํ๋ ๊ฒ๊ณผ NSManagedObject์ relationship์ ๊ตฌ์ฒดํํ์ฌ ์ํ๋ ํํ๋ก์ ๊ฐ์ฒด๋ฅผ ์ ์ฅํด๋ณด๊ณ ์ ํ์๊ณ , ๊ฐ๋ณ์ ์ธ class์ ๊ทธ property๋ฅผ ๊ตฌ์กฐํํ์ฌ ๊ธฐ์กด์ ๋ก์ง์์๋ ๋ฌด๋ฆฌ์์ด ํ์ฉํ ์ ์๋๋ก ๊ตฌ์ฑํ์์ต๋๋ค.
Realm๊ณผ ๋น๊ตํด์ ์์ฌ์ ๋ ์ ์ ๋ณ๋์ NSManagedObject ๊ฐ์ฒด๋ ์์ ์ด ๋ฒ๊ฑฐ๋กญ๋ค๋ ์ ๊ณผ ๊ตณ์ด ๋น์ทํ ํํ์ ๊ฐ์ฒด๋ค์ ๋ณ๋๋ก ์ถ์ํํ์ฌ ๊ฐ์์ useCase ์์ญ์์ ํ์ฉํ๋ ์ ์์ ๋นํจ์จ์ ์ด์์ต๋๋ค. ํ์ง๋ง, immutableํ๊ฒ ๊ฐ์ฒด๋ฅผ ์ ์ฅํ๋ค๋ ์ ์์ ๊ธฐ๋ฐ์ฑ๊ณผ ์์ ์ด ์ด๋ ต๋ค๋ ์ ์ ๋ณด์์ ์ธ ์ธก๋ฉด์์ ๋ฉ๋ฆฌํธ๊ฐ ์๋ ๊ฒ ๊ฐ์ต๋๋ค. ๋ฌผ๋ก Cloud๋ฅผ ํ์ฉํ ์ ์๋ค๋ ๋ถ๋ถ๋ ํ์คํ ์ฅ์ ์ ๋๋ค. ์ฌ์ฉํ๋ ์ธก๋ฉด์์ ๊ณ ๋ คํ์ฌ DB๋ฅผ ๋ฌด์์ ์ฌ์ฉํ ์ง๋ ํญ์ ๊ณ ๋ฏผ๋๋ ์ ์ ๋๋ค.
๊ตฌ์ฒด์ ์ผ๋ก ํด๋ฆฐ ์ํคํ ์ณ๋ผ๋ ๊ฐ๋ ์ ํ์คํ๊ฒ ์ฒด๊ฐํ ์์ค์ ์๋์์ง๋ง ํ์คํ ๋ ์ด์ด์ ๊ตฌ๋ถ๊ณผ ์์ฒญ์ ๋ฐ๋ฅธ ๋ฉ์์ง ์ ๋ฌ์ ์๋ฟ๋ ๋ถ๋ถ์ด ์์์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ Network ๋ ์ด์ด์์ ๋ฐ์ ๊ฐ์ฒด๋ฅผ ๊ทธ๋๋ก ์ฐ๋ ๊ฒ ์๋ ํ์์ ๋ฐ๋ผ ์์ฒญํ๊ณ ์ํ๋ ํํ๋ก ๊ฐ๊ณตํ์ฌ ์ฌ์ฉํ๋ ๋ฐฉ์์ ๋ฐ์ดํฐ์ ๋ณดํธ์ ๋๋ถ์ด ์บก์ํ์ ์์ด์ ๋ง์ ์ด์ ์ด ์์์ต๋๋ค. ๋ฆฌ์กํฐ๋ธํ ๊ตฌ์ฑ๊ณผ ํฉ์ณ์ก์ ๋์ ํ๊ธ๋ ฅ๋ ๊ทธ๋ ๊ณ ๋ง์ด ๋ถ์กฑํ Clean์ด์ง๋ง ์ฐจ์ธฐ์ฐจ์ธฐ ์ํคํ ์ณ๋ฅผ ๊ตฌ์ฑํ๊ณ ํด๋๋ง๊ณผ ๋๋ถ์ด ์ฃผ์ ๊ณผ ๋ถ๋ฆฌ์ ๊ฐ๋ ์ ์ดํดํด๋๊ฐ๋ ๊ฑฐ ๊ฐ์ต๋๋ค ์ค์ ๋ก ๋ชจ๋ํ๋ฅผ ํตํ ๋ถ๋ฆฌ๋ก ํ๋ก์ ํธ๋ฅผ ๊ตฌ์ฑํ๋ ๊ฒ์ ๋ชฉํ๋ก ๊ณ์ ํด๋ด์ผ๊ฒ ์ต๋๋ค