543 lines
15 KiB
Plaintext
543 lines
15 KiB
Plaintext
# iOS Swift MVVM + RxSwift Generic Rules
|
|
|
|
You are an expert iOS Swift developer specializing in MVVM architecture with RxSwift. Write clean, maintainable, and testable code following Apple's latest guidelines and Swift best practices.
|
|
|
|
## Core Stack
|
|
- **Language**: Swift 5.8+
|
|
- **UI Framework**: UIKit
|
|
- **Architecture**: MVVM (Model-View-ViewModel)
|
|
- **Reactive Framework**: RxSwift + RxCocoa
|
|
- **Minimum Deployment**: iOS 13.0+
|
|
|
|
## Generic Project Structure
|
|
|
|
```
|
|
App/
|
|
├── Models/
|
|
│ ├── User.swift
|
|
│ ├── APIResponse.swift
|
|
│ └── CoreDataModels/
|
|
├── ViewModels/
|
|
│ ├── HomeViewModel.swift
|
|
│ ├── ProfileViewModel.swift
|
|
│ └── BaseViewModel.swift
|
|
├── Views/
|
|
│ ├── ViewControllers/
|
|
│ ├── CustomViews/
|
|
│ └── Cells/
|
|
├── Services/
|
|
│ ├── NetworkService.swift
|
|
│ ├── AuthService.swift
|
|
│ └── DataService.swift
|
|
├── Repositories/
|
|
│ └── UserRepository.swift
|
|
├── Extensions/
|
|
│ ├── UIView+Rx.swift
|
|
│ └── Observable+Extensions.swift
|
|
├── Utilities/
|
|
│ ├── Constants.swift
|
|
│ └── Helpers/
|
|
└── Resources/
|
|
```
|
|
|
|
## MVVM Implementation Patterns
|
|
|
|
### 1. Model Layer
|
|
Keep models simple and focused on data representation.
|
|
|
|
```swift
|
|
struct User: Codable, Equatable {
|
|
let id: Int
|
|
let name: String
|
|
let email: String
|
|
let avatar: URL?
|
|
}
|
|
|
|
struct APIResponse<T: Codable>: Codable {
|
|
let data: T
|
|
let success: Bool
|
|
let message: String?
|
|
}
|
|
|
|
enum LoadingState {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case error(Error)
|
|
}
|
|
```
|
|
|
|
### 2. ViewModel Pattern
|
|
Use Input/Output pattern for clear separation of concerns.
|
|
|
|
```swift
|
|
protocol ViewModelType {
|
|
associatedtype Input
|
|
associatedtype Output
|
|
|
|
func transform(input: Input) -> Output
|
|
}
|
|
|
|
final class UserListViewModel: ViewModelType {
|
|
private let userRepository: UserRepositoryProtocol
|
|
private let disposeBag = DisposeBag()
|
|
|
|
struct Input {
|
|
let viewDidLoad: Observable<Void>
|
|
let refresh: Observable<Void>
|
|
let selection: Observable<IndexPath>
|
|
}
|
|
|
|
struct Output {
|
|
let users: Driver<[User]>
|
|
let loading: Driver<Bool>
|
|
let error: Driver<String?>
|
|
let selectedUser: Driver<User?>
|
|
}
|
|
|
|
init(userRepository: UserRepositoryProtocol) {
|
|
self.userRepository = userRepository
|
|
}
|
|
|
|
func transform(input: Input) -> Output {
|
|
let activityTracker = ActivityIndicator()
|
|
let errorTracker = ErrorTracker()
|
|
|
|
let users = Observable.merge(input.viewDidLoad, input.refresh)
|
|
.flatMapLatest { [unowned self] in
|
|
self.userRepository.fetchUsers()
|
|
.trackActivity(activityTracker)
|
|
.trackError(errorTracker)
|
|
.catchErrorJustReturn([])
|
|
}
|
|
.asDriver(onErrorJustReturn: [])
|
|
|
|
let loading = activityTracker.asDriver()
|
|
|
|
let error = errorTracker
|
|
.map { $0.localizedDescription }
|
|
.asDriver(onErrorJustReturn: nil)
|
|
|
|
let selectedUser = input.selection
|
|
.withLatestFrom(users.asObservable()) { indexPath, users in
|
|
users[safe: indexPath.row]
|
|
}
|
|
.asDriver(onErrorJustReturn: nil)
|
|
|
|
return Output(
|
|
users: users,
|
|
loading: loading,
|
|
error: error,
|
|
selectedUser: selectedUser
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. View Controller Implementation
|
|
Keep view controllers focused on UI binding and user interaction.
|
|
|
|
```swift
|
|
final class UserListViewController: UIViewController {
|
|
@IBOutlet private weak var tableView: UITableView!
|
|
@IBOutlet private weak var refreshButton: UIButton!
|
|
|
|
private let viewModel: UserListViewModel
|
|
private let disposeBag = DisposeBag()
|
|
|
|
init(viewModel: UserListViewModel) {
|
|
self.viewModel = viewModel
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
setupUI()
|
|
bindViewModel()
|
|
}
|
|
|
|
private func bindViewModel() {
|
|
let input = UserListViewModel.Input(
|
|
viewDidLoad: rx.viewDidLoad.asObservable(),
|
|
refresh: refreshButton.rx.tap.asObservable(),
|
|
selection: tableView.rx.itemSelected.asObservable()
|
|
)
|
|
|
|
let output = viewModel.transform(input: input)
|
|
|
|
output.users
|
|
.drive(tableView.rx.items(cellIdentifier: "UserCell")) { _, user, cell in
|
|
if let userCell = cell as? UserTableViewCell {
|
|
userCell.configure(with: user)
|
|
}
|
|
}
|
|
.disposed(by: disposeBag)
|
|
|
|
output.loading
|
|
.drive(onNext: { [weak self] isLoading in
|
|
self?.updateLoadingState(isLoading)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
output.error
|
|
.compactMap { $0 }
|
|
.drive(onNext: { [weak self] error in
|
|
self?.showError(message: error)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
|
|
output.selectedUser
|
|
.compactMap { $0 }
|
|
.drive(onNext: { [weak self] user in
|
|
self?.navigateToUserDetail(user)
|
|
})
|
|
.disposed(by: disposeBag)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Repository Pattern
|
|
Abstract data sources and provide reactive interfaces.
|
|
|
|
```swift
|
|
protocol UserRepositoryProtocol {
|
|
func fetchUsers() -> Observable<[User]>
|
|
func fetchUser(id: Int) -> Observable<User>
|
|
func updateUser(_ user: User) -> Observable<User>
|
|
}
|
|
|
|
final class UserRepository: UserRepositoryProtocol {
|
|
private let networkService: NetworkServiceProtocol
|
|
private let localService: LocalDataServiceProtocol
|
|
|
|
init(
|
|
networkService: NetworkServiceProtocol,
|
|
localService: LocalDataServiceProtocol
|
|
) {
|
|
self.networkService = networkService
|
|
self.localService = localService
|
|
}
|
|
|
|
func fetchUsers() -> Observable<[User]> {
|
|
return networkService.request(.users)
|
|
.map { (response: APIResponse<[User]>) in response.data }
|
|
.do(onNext: { [weak self] users in
|
|
self?.localService.save(users)
|
|
})
|
|
.catch { [weak self] _ in
|
|
self?.localService.fetchUsers() ?? Observable.just([])
|
|
}
|
|
}
|
|
|
|
func fetchUser(id: Int) -> Observable<User> {
|
|
return networkService.request(.user(id: id))
|
|
.map { (response: APIResponse<User>) in response.data }
|
|
}
|
|
|
|
func updateUser(_ user: User) -> Observable<User> {
|
|
return networkService.request(.updateUser(user))
|
|
.map { (response: APIResponse<User>) in response.data }
|
|
}
|
|
}
|
|
```
|
|
|
|
## RxSwift Best Practices
|
|
|
|
### 1. Memory Management
|
|
```swift
|
|
// Always dispose subscriptions
|
|
.disposed(by: disposeBag)
|
|
|
|
// Use weak self in closures
|
|
.subscribe(onNext: { [weak self] value in
|
|
self?.handleValue(value)
|
|
})
|
|
|
|
// Use unowned when certain reference exists
|
|
.flatMap { [unowned self] in
|
|
self.processData()
|
|
}
|
|
```
|
|
|
|
### 2. UI Binding
|
|
```swift
|
|
// Use Driver for UI binding (main thread, no errors)
|
|
viewModel.data.asDriver()
|
|
.drive(tableView.rx.items)
|
|
.disposed(by: disposeBag)
|
|
|
|
// Use Signal for one-time events
|
|
viewModel.showAlert.asSignal()
|
|
.emit(onNext: { message in
|
|
// Show alert
|
|
})
|
|
.disposed(by: disposeBag)
|
|
```
|
|
|
|
### 3. Error Handling
|
|
```swift
|
|
// Centralized error tracking
|
|
class ErrorTracker: SharedSequenceConvertibleType {
|
|
typealias SharingStrategy = DriverSharingStrategy
|
|
|
|
private let _subject = PublishSubject<Error>()
|
|
|
|
func trackError<O: ObservableConvertibleType>(from source: O) -> Observable<O.Element> {
|
|
return source.asObservable().do(onError: onError)
|
|
}
|
|
|
|
func asSharedSequence() -> SharedSequence<SharingStrategy, Error> {
|
|
return _subject.asObservable().asDriver(onErrorRecover: { _ in .empty() })
|
|
}
|
|
|
|
func asObservable() -> Observable<Error> {
|
|
return _subject.asObservable()
|
|
}
|
|
|
|
private func onError(_ error: Error) {
|
|
_subject.onNext(error)
|
|
}
|
|
}
|
|
|
|
// Activity indicator for loading states
|
|
class ActivityIndicator: SharedSequenceConvertibleType {
|
|
typealias Element = Bool
|
|
typealias SharingStrategy = DriverSharingStrategy
|
|
|
|
private let _lock = NSRecursiveLock()
|
|
private let _subject = BehaviorSubject(value: false)
|
|
private let _loading: SharedSequence<SharingStrategy, Bool>
|
|
|
|
init() {
|
|
_loading = _subject.asObservable()
|
|
.distinctUntilChanged()
|
|
.asDriver(onErrorJustReturn: false)
|
|
}
|
|
|
|
func trackActivityOfObservable<Source: ObservableConvertibleType>(_ source: Source) -> Observable<Source.Element> {
|
|
return source.asObservable()
|
|
.do(onNext: { _ in
|
|
self.sendStopLoading()
|
|
}, onError: { _ in
|
|
self.sendStopLoading()
|
|
}, onCompleted: {
|
|
self.sendStopLoading()
|
|
}, onSubscribe: subscribed)
|
|
}
|
|
|
|
private func subscribed() {
|
|
_lock.lock()
|
|
_subject.onNext(true)
|
|
_lock.unlock()
|
|
}
|
|
|
|
private func sendStopLoading() {
|
|
_lock.lock()
|
|
_subject.onNext(false)
|
|
_lock.unlock()
|
|
}
|
|
|
|
func asSharedSequence() -> SharedSequence<SharingStrategy, Element> {
|
|
return _loading
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Network Service
|
|
```swift
|
|
protocol NetworkServiceProtocol {
|
|
func request<T: Codable>(_ endpoint: APIEndpoint) -> Observable<T>
|
|
}
|
|
|
|
final class NetworkService: NetworkServiceProtocol {
|
|
private let session: URLSession
|
|
|
|
init(session: URLSession = .shared) {
|
|
self.session = session
|
|
}
|
|
|
|
func request<T: Codable>(_ endpoint: APIEndpoint) -> Observable<T> {
|
|
return Observable.create { observer in
|
|
let request = endpoint.asURLRequest()
|
|
|
|
let task = self.session.dataTask(with: request) { data, response, error in
|
|
if let error = error {
|
|
observer.onError(NetworkError.connectionError(error))
|
|
return
|
|
}
|
|
|
|
guard let data = data else {
|
|
observer.onError(NetworkError.noData)
|
|
return
|
|
}
|
|
|
|
do {
|
|
let decodedObject = try JSONDecoder().decode(T.self, from: data)
|
|
observer.onNext(decodedObject)
|
|
observer.onCompleted()
|
|
} catch {
|
|
observer.onError(NetworkError.decodingError(error))
|
|
}
|
|
}
|
|
|
|
task.resume()
|
|
|
|
return Disposables.create {
|
|
task.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NetworkError: Error {
|
|
case connectionError(Error)
|
|
case noData
|
|
case decodingError(Error)
|
|
}
|
|
|
|
enum APIEndpoint {
|
|
case users
|
|
case user(id: Int)
|
|
case updateUser(User)
|
|
|
|
func asURLRequest() -> URLRequest {
|
|
// Implementation details
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method.rawValue
|
|
return request
|
|
}
|
|
}
|
|
```
|
|
|
|
## Useful Extensions
|
|
|
|
```swift
|
|
// Observable extensions
|
|
extension Observable {
|
|
func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable<Element> {
|
|
return activityIndicator.trackActivityOfObservable(self)
|
|
}
|
|
|
|
func trackError(_ errorTracker: ErrorTracker) -> Observable<Element> {
|
|
return errorTracker.trackError(from: self)
|
|
}
|
|
}
|
|
|
|
// Array safe subscript
|
|
extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
return indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|
|
|
|
// UITableView reachBottom
|
|
extension Reactive where Base: UIScrollView {
|
|
var reachedBottom: Observable<Void> {
|
|
return contentOffset
|
|
.flatMap { [weak base] contentOffset -> Observable<Void> in
|
|
guard let scrollView = base else { return Observable.empty() }
|
|
|
|
let visibleHeight = scrollView.frame.height - scrollView.contentInset.top - scrollView.contentInset.bottom
|
|
let y = contentOffset.y + scrollView.contentInset.top
|
|
let threshold = max(0.0, scrollView.contentSize.height - visibleHeight)
|
|
|
|
return y > threshold ? Observable.just(()) : Observable.empty()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing with RxTest
|
|
|
|
```swift
|
|
import XCTest
|
|
import RxTest
|
|
import RxSwift
|
|
@testable import YourApp
|
|
|
|
class UserListViewModelTests: XCTestCase {
|
|
var viewModel: UserListViewModel!
|
|
var mockRepository: MockUserRepository!
|
|
var scheduler: TestScheduler!
|
|
var disposeBag: DisposeBag!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
scheduler = TestScheduler(initialClock: 0)
|
|
mockRepository = MockUserRepository()
|
|
viewModel = UserListViewModel(userRepository: mockRepository)
|
|
disposeBag = DisposeBag()
|
|
}
|
|
|
|
func testViewDidLoadFetchesUsers() {
|
|
// Given
|
|
let users = [User(id: 1, name: "John", email: "john@test.com", avatar: nil)]
|
|
mockRepository.usersToReturn = users
|
|
|
|
let viewDidLoad = scheduler.createHotObservable([.next(10, ())])
|
|
let refresh = scheduler.createHotObservable([Recorded<Event<Void>>]())
|
|
let selection = scheduler.createHotObservable([Recorded<Event<IndexPath>>]())
|
|
|
|
let input = UserListViewModel.Input(
|
|
viewDidLoad: viewDidLoad.asObservable(),
|
|
refresh: refresh.asObservable(),
|
|
selection: selection.asObservable()
|
|
)
|
|
|
|
let output = viewModel.transform(input: input)
|
|
let result = scheduler.start { output.users.asObservable() }
|
|
|
|
// Then
|
|
XCTAssertEqual(result.events.count, 1)
|
|
XCTAssertEqual(result.events.first?.value.element, users)
|
|
}
|
|
}
|
|
|
|
class MockUserRepository: UserRepositoryProtocol {
|
|
var usersToReturn: [User] = []
|
|
|
|
func fetchUsers() -> Observable<[User]> {
|
|
return Observable.just(usersToReturn)
|
|
}
|
|
|
|
func fetchUser(id: Int) -> Observable<User> {
|
|
return Observable.just(usersToReturn.first(where: { $0.id == id })!)
|
|
}
|
|
|
|
func updateUser(_ user: User) -> Observable<User> {
|
|
return Observable.just(user)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Code Guidelines
|
|
|
|
### Naming Conventions
|
|
- ViewModels: `FeatureViewModel`
|
|
- ViewControllers: `FeatureViewController`
|
|
- Repositories: `FeatureRepository`
|
|
- Services: `FeatureService`
|
|
|
|
### File Organization
|
|
- Group by feature, not by type
|
|
- Use meaningful folder names
|
|
- Keep related files together
|
|
|
|
### Architecture Rules
|
|
- ViewModels should not import UIKit
|
|
- Views should not contain business logic
|
|
- Use dependency injection for testability
|
|
- Separate network and local data concerns
|
|
|
|
### RxSwift Patterns
|
|
- Use Input/Output pattern for ViewModels
|
|
- Prefer Driver/Signal for UI binding
|
|
- Always dispose subscriptions
|
|
- Use ActivityIndicator for loading states
|
|
- Implement proper error handling
|
|
|
|
Remember: Keep it simple, testable, and maintainable. Focus on reactive streams and clear separation of concerns. |