Smth Client Development: SwiftUI Practices from MVVM Architecture to Multi-Platform Adaptation
This article explores building a multi-platform SMTH BBS client (iOS/iPadOS/macOS) with SwiftUI, detailing MVVM architecture, dependency injection, pagination state management, SwiftUI best practices, and cross-platform adaptation strategies.
SMTH BBS (www.newsmth.net) is one of the earliest BBS forums in China, originating from Tsinghua University with over 20 years of history. As a renowned tech community, it brings together a large number of technology enthusiasts, researchers, and industry experts, covering discussions on technology, academia, lifestyle, and more.
SMTH BBS is well known for its high-quality technical discussions and vibrant community atmosphere, making it an important platform for developers and tech professionals to gain knowledge and exchange experience.
As a long-time user of SMTH BBS, I frequently browse forum content on mobile devices. However, when using existing clients from the App Store, I found several issues:
- Poor user experience: Outdated interface design, sluggish interactions, and too many ads
- Incomplete features: Missing common features such as browsing history and font settings
- Infrequent updates: Many apps are rarely updated and fail to support new system features
- Lack of multi-platform support: No macOS version available for desktop use
Based on these pain points, I decided to develop a modern SMTH BBS client from scratch, using the latest SwiftUI framework, supporting iOS, iPadOS, and macOS platforms, and delivering a better user experience.
Project URL: https://github.com/bitnpc/Smth
This project is open source under the MIT license. Stars and forks are welcome. If you are also a SMTH BBS user or interested in SwiftUI multi-platform development, feel free to contribute.
- Project Overview
- Software Engineering Architecture
- SwiftUI Practices and Considerations
- Multi-Platform Adaptation Strategy
- Code Quality Assurance and CI/CD
- Summary and Outlook
Smth is a modern forum client built with SwiftUI, supporting iOS, iPadOS, and macOS. The project adopts the MVVM architecture pattern, achieving clear separation of concerns and a highly testable code structure.
- Hot topic browsing (waterfall layout + paginated loading)
- Board navigation and topic details
- Image viewer (supports multi-image swiping and zooming)
- User login and personal profile
- Favorites management, message center, search functionality
- Local caching (browsing history, drafts)
Good architecture design is the foundation of a successful project. The Smth project adopts a layered architecture pattern, with clear separation from the UI layer to the data layer, ensuring code maintainability and extensibility.
The project adopts a layered architecture where each layer has clear responsibilities:
graph TB A[View Layer<br/>SwiftUI Views] --> B[ViewModel Layer<br/>@ObservableObject] B --> C[Repository Layer<br/>Protocol-based] C --> D[API Service Layer<br/>Network Requests] E[Dependency Injection<br/>AppContainer] -.-> B E -.-> C E -.-> D F[Model Layer<br/>Data Models] --> B F --> C G[Core Services<br/>Auth, Cache, Utils] --> B G --> CMVVM (Model-View-ViewModel) is the core architecture pattern of the project, with each layer having the following responsibilities:
| Layer | Responsibility | Example |
|---|---|---|
| View | UI rendering, user interaction | HomeView, TopicRowView |
| ViewModel | Business logic, state management | TopicListViewModel, FavoritesViewModel |
| Model | Data models | Topic, Article, Board |
| Repository | Data access abstraction | TopicRepository, MessageRepository |
| Service | Network requests, business services | APIService, BrowsingHistoryStore |
Using TopicListViewModel as an example, here is the core MVVM implementation:
@MainActorfinal class TopicListViewModel: ObservableObject { @Published private(set) var topics: [Topic] = [] @Published private(set) var isLoadingPage = false @Published private(set) var isRefreshing = false @Published private(set) var errorMessage: String?
private let repository: TopicRepositoryProtocol private var paginationState = PaginationState<Topic>()
init(repository: TopicRepositoryProtocol = AppContainer.shared.resolve(TopicRepositoryProtocol.self)) { self.repository = repository }
func loadInitialIfNeeded() async { if topics.isEmpty { await loadInitialPage() } }
func loadNextPageIfNeeded(currentItem item: Topic?) { guard let item else { return } let thresholdIndex = topics.index(topics.endIndex, offsetBy: -5, limitedBy: topics.startIndex) ?? topics.startIndex if topics.firstIndex(where: { $0.id == item.id }) == thresholdIndex { Task { await loadNextPage() } } }
private func loadPage() async { guard let nextPage = paginationState.startLoadingNextPage() else { return } do { let newItems = try await repository.fetchTopics(in: boardID, page: nextPage, pageSize: pageSize) paginationState.completeLoading(with: newItems, pageSize: pageSize) topics = paginationState.items } catch { errorMessage = error.localizedDescription } }}Design highlights:
- @MainActor guarantees thread safety: All UI updates execute on the main thread
- @Published properties drive the UI: SwiftUI automatically responds to state changes
- Dependency injection: Repositories are injected via
AppContainer, making testing easier - Error handling: Exceptions are caught and
errorMessageis updated for the View layer to display
The Repository layer abstracts data access logic and provides a unified interface. The benefits of this design include:
- Testability: Easily create Mock Repositories for unit testing
- Maintainability: Changing data sources (e.g., switching from API to a local database) only requires modifying the Repository implementation
- Single responsibility: Repositories are solely responsible for data fetching, not business logic
struct TopicRepository: TopicRepositoryProtocol { private let apiService: APIService
func fetchTopics(in boardID: String, page: Int, pageSize: Int) async throws -> [Topic] { let endpoint = APIEndpoint.topicList(boardID: boardID, page: page, pageSize: pageSize).toEndpoint() let response: TopicResponse = try await apiService.request(endpoint) return response.data.topics }}The project uses a custom dependency injection container AppContainer to manage all dependencies in a unified manner:
final class AppContainer: DependencyContainer { static let shared = AppContainer()
private lazy var apiService: APIService = DefaultAPIService() private lazy var topicRepository: TopicRepositoryProtocol = TopicRepository(apiService: apiService)
func resolve<T>(_ type: T.Type) -> T { if type == TopicRepositoryProtocol.self { return topicRepository as! T } // ... other dependencies }}Design advantages:
- Singleton pattern:
AppContainer.sharedensures a single global instance - Lazy initialization: Uses
lazy varto create dependencies on demand - Type safety: Dependencies are retrieved via the generic
resolve<T>method
The project implements a generic pagination state management class PaginationState to unify list pagination logic:
struct PaginationState<Item: Identifiable & Hashable> { private(set) var items: [Item] = [] private(set) var currentPage: Int = 0 private(set) var isLoadingPage = false private(set) var canLoadMorePages = true
mutating func startLoadingNextPage() -> Int? { guard !isLoadingPage, canLoadMorePages else { return nil } isLoadingPage = true currentPage += 1 return currentPage }
mutating func completeLoading(with newItems: [Item], pageSize: Int) { items.append(contentsOf: newItems) isLoadingPage = false canLoadMorePages = !newItems.isEmpty }}Core features:
- Generic design: Supports any
Identifiable & Hashabletype - Encapsulated state: Prevents external direct state modification
- Duplicate load prevention: Uses
isLoadingPageflag to prevent concurrent requests
classDiagram class View { <<SwiftUI View>> +body: some View +@StateObject viewModel }
class ViewModel { <<ObservableObject>> +@Published state +async loadData() }
class Repository { <<Protocol>> +fetchData() async throws }
class APIService { <<Protocol>> +request() async throws }
class AppContainer { +shared: AppContainer +resolve() T }
View --> ViewModel : observes ViewModel --> Repository : uses Repository --> APIService : uses AppContainer --> ViewModel : injects AppContainer --> Repository : injectsSwiftUI, as Apple’s modern UI framework, adopts a declarative programming paradigm that makes UI development more concise and efficient. This section shares practical experiences and considerations from the project.
The core idea of SwiftUI is to describe the “state” of the UI rather than the “steps” to build it. This declarative approach makes code more intuitive:
var body: some View { ScrollView { LazyVStack(spacing: AppTheme.compactSpacing) { ForEach(viewModel.topics) { topic in NavigationLink(value: topic) { TopicRowView(topic: topic) } .onAppear { viewModel.loadNextPageIfNeeded(currentItem: topic) } } } }}Key points:
- LazyVStack: Lazy loading for better performance
- onAppear: Triggers pagination loading
- Data-driven: UI automatically responds to changes in
viewModel.topics
SwiftUI provides multiple state management approaches. Choosing the right property wrapper is important:
| Property Wrapper | Purpose | Use Case |
|---|---|---|
@State | View-internal state | Temporary UI state (e.g., selected item) |
@StateObject | View-owned ObservableObject | ViewModel lifecycle tied to view |
@ObservedObject | Externally passed ObservableObject | Shared ViewModel |
@EnvironmentObject | Environment object | Global state (e.g., login status) |
@Environment | Environment values | System settings (e.g., color scheme) |
Best practices:
struct HomeView: View { @EnvironmentObject private var browsingHistory: BrowsingHistoryStore @Environment(\.colorScheme) private var colorScheme @StateObject private var viewModel = NaviTopicListViewModel() @State private var selectedIndex: Int = 0}- @StateObject: Used to create and own a ViewModel
- @EnvironmentObject: Used to share global state
- @Environment: Used to access system environment values
Reusable styles are implemented through ViewModifiers to maintain UI consistency:
extension View { func smthScaffoldBackground() -> some View { modifier(ScaffoldBackgroundModifier()) }
func smthSurfaceBackground(subdued: Bool = false) -> some View { modifier(SurfaceBackgroundModifier(subdued: subdued)) }}Advantages:
- Code reuse: Unified application styles
- Easy to maintain: Style changes only require updating the ViewModifier
- Chainable calls:
.smthScaffoldBackground()is concise and elegant
List performance optimization
❌ Incorrect: Using VStack to render large amounts of data
VStack { ForEach(items) { item in ItemView(item: item) }}✅ Correct: Using LazyVStack or List
LazyVStack { ForEach(items) { item in ItemView(item: item) }}View reconstruction optimization
❌ Incorrect: Creating complex objects in body
var body: some View { let expensiveData = computeExpensiveData() return Text(expensiveData)}✅ Correct: Using @State to cache computed results
@State private var expensiveData: String = ""
var body: some View { Text(expensiveData) .onAppear { expensiveData = computeExpensiveData() }}When handling asynchronous operations in SwiftUI, use Task and async/await:
.onAppear { Task { await navigationViewModel.loadNavigationsIfNeeded() }}Considerations:
- Use
Task { }to start async tasks in Views - ViewModel methods should be marked as
async - Use
@MainActorto ensure UI updates happen on the main thread
SwiftUI supports platform-specific code using #if os():
var body: some View { #if os(iOS) ImageViewerUIKit(images: images, initialIndex: initialIndex, isPresented: $isPresented) #else ImageViewerSwiftUI(images: images, initialIndex: initialIndex, isPresented: $isPresented) #endif}The project breaks the UI into reusable components, each with a single responsibility:
struct TopicRowView: View { let topic: Topic let isVisited: Bool
var body: some View { VStack(alignment: .leading, spacing: 5) { Text(topic.subject) .font(.headline) // ... other UI elements } .background(AppTheme.surfaceBackground(for: colorScheme)) }}Design principles:
- Single responsibility: Each component handles one function
- Reusability: Parameter configuration adapts to different scenarios
- Accessibility:
.accessibilityLabeladded for VoiceOver support
Multi-platform support is an important requirement for modern application development. The Smth project supports iOS, iPadOS, and macOS platforms with a single codebase, maintaining code uniformity while fully leveraging each platform’s native features.
| Feature | iOS | macOS |
|---|---|---|
| Navigation | TabView bottom navigation | NavigationSplitView sidebar |
| Interaction | Touch gestures | Mouse + keyboard |
| Window management | Full-screen app | Multi-window support |
| Image viewer | UIKit (smooth gestures) | SwiftUI (mouse adapted) |
| Sheet presentation | Bottom sheet | Standalone window |
| Toolbar | Navigation bar | Menu bar + toolbar |
The project selects different navigation approaches based on the platform in ContentView:
var body: some View { Group { #if os(macOS) macSidebarLayout #else if horizontalSizeClass == .compact { tabLayout } else { sidebarLayout } #endif }}iOS implementation (TabView):
private var tabLayout: some View { TabView(selection: $selection) { NavigationStack { HomeView() } .tabItem { Label("Home", systemImage: "house") } // ... other tabs }}macOS implementation (NavigationSplitView):
#if os(macOS)private var macSidebarLayout: some View { NavigationSplitView(columnVisibility: $columnVisibility) { macSidebar.frame(minWidth: 240, idealWidth: 280) } content: { macContentStack.frame(minWidth: 300, idealWidth: 380) } detail: { macDetailPlaceholder.frame(minWidth: 500, idealWidth: 600) }}#endifDesign highlights:
- Three-column layout: Sidebar + content list + detail view
- Responsive widths: Column widths controlled via
minWidth/idealWidth/maxWidth - State synchronization: Data automatically refreshes when login state changes
Since iOS and macOS have different interaction patterns, the project implements two versions of the image viewer:
iOS (UIKit implementation):
#if os(iOS)private struct ImageViewerUIKit: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> ImagePageViewController { ImagePageViewController(images: images, initialIndex: initialIndex) }}#endifmacOS (SwiftUI implementation):
#if !os(iOS)private struct ImageViewerSwiftUI: View { @State private var currentIndex: Int @State private var scale: CGFloat = 1.0
var body: some View { ScrollView([.horizontal, .vertical]) { CachedAsyncImage(url: URL(string: images[currentIndex])) .scaleEffect(scale) } .gesture(MagnificationGesture()) }}#endifDifferences explained:
- iOS: Uses
UIPageViewControllerfor smooth swipe transitions with better gesture experience - macOS: Uses SwiftUI’s
ScrollView+MagnificationGesture, adapted for mouse interaction
iOS (bottom sheet):
#if os(iOS).sheet(isPresented: $showProfileView) { ProfileView() .presentationDetents([.large])}#endifmacOS (standalone window):
#elseif os(macOS).sheet(isPresented: $showProfileView) { ProfileView() .frame(minWidth: 600, minHeight: 500)}#endifgraph LR A[ContentView] --> B{Platform detection} B -->|iOS| C[TabView<br/>Bottom navigation] B -->|iPadOS| D[NavigationSplitView<br/>Sidebar] B -->|macOS| E[NavigationSplitView<br/>Three-column layout]
F[ImageViewer] --> G{Platform detection} G -->|iOS| H[UIKit<br/>UIPageViewController] G -->|macOS| I[SwiftUI<br/>ScrollView + Gesture]Core principles:
- Conditional compilation: Use
#if os()to differentiate platform code - Unified interface: Keep ViewModel and Repository layers platform-agnostic
- Platform characteristics: Fully leverage each platform’s native experience
- Responsive layout: Use
horizontalSizeClassto adapt to different screen sizes
Code quality is key to long-term project maintenance. The Smth project uses SwiftLint for code style checks and GitHub Actions CI/CD automation to ensure code quality and continuous integration.
The project uses SwiftLint for code style checks, configured in swiftlint.yml:
disabled_rules: - identifier_name - trailing_whitespace
included: - App - SmthTests
line_length: warning: 140 error: 180
function_body_length: warning: 175 error: 200
cyclomatic_complexity: warning: 20 error: 25Configuration highlights:
- Disabled rules:
identifier_name(allows more flexible naming),trailing_whitespace(handled by the editor) - Scope: Only checks
AppandSmthTestsdirectories - Line length: Warning at 140 characters, error at 180 characters
- Complexity control: Function body length and cyclomatic complexity have clear limits
Local checks:
swiftlint --config swiftlint.ymlswiftlint --fix --config swiftlint.yml # Auto-fixFastlane integration:
lane :lint do sh("swiftlint --config swiftlint.yml")endThe project uses GitHub Actions for continuous integration:
name: CI
on: push: branches: [main, master, develop] pull_request:
jobs: build-and-test: runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '16.2' - name: Install SwiftLint run: brew install swiftlint - name: Run Fastlane CI run: bundle exec fastlane ciWorkflow features:
- Trigger conditions: Push to main branches or create a Pull Request
- Runtime environment: macOS 14, fixed Xcode 16.2
- Cache optimization: Caches Swift Package Manager dependencies
- Automation: Automatically installs dependencies and runs checks
graph TB A[Code push/PR] --> B[GitHub Actions triggered] B --> C[Setup Xcode 16.2] C --> D[Install SwiftLint] D --> E[Run Fastlane CI] E --> F[SwiftLint check] F --> G{Checks passed?} G -->|No| H[CI failed] G -->|Yes| I[Run unit tests] I --> J{Tests passed?} J -->|No| H J -->|Yes| K[Build project] K --> L{Build succeeded?} L -->|No| H L -->|Yes| M[CI succeeded]Fastlane’s ci lane integrates code checks and testing:
lane :ci do lint testsendExecution order:
- Lint check: Runs SwiftLint code style checks
- Unit tests: Executes all unit tests
The project uses the XCTest framework for unit testing, with testability achieved through dependency injection:
ViewModel test example:
final class TopicListViewModelTests: XCTestCase { func testInitialLoadFetchesTopics() async throws { let repository = StubTopicRepository( hotTopics: { page, size in Self.mockTopics(page: page, pageSize: size) } ) let viewModel = TopicListViewModel(repository: repository)
await viewModel.loadInitialIfNeeded()
XCTAssertEqual(viewModel.topics.count, 20) XCTAssertFalse(viewModel.isLoadingPage) }}Testing highlights:
- Dependency injection: Uses
StubTopicRepositoryto mock the data source - Async testing: Uses
async/awaitto test asynchronous operations - Isolation: Each test is independent and relies on no external state
Current test coverage in the project:
| Module | Test File | Coverage |
|---|---|---|
| ViewModel | TopicListViewModelTests.swift | Pagination loading, initial load |
| Store | BrowsingHistoryStoreTests.swift | Browsing history, deduplication |
| Settings | FontSettingsTests.swift | Font settings persistence |
Areas to improve:
- Repository layer tests
- API Service tests
- UI component tests (Snapshot Testing)
Before committing code, it is recommended to run the CI flow locally:
bundle installbundle exec fastlane ci- Run
swiftlintto ensure code style compliance - Run unit tests to ensure functionality
- Check for compiler warnings
- Ensure all tests pass
When CI fails:
- Check the logs: GitHub Actions displays detailed error information
- Reproduce locally: Run the same commands locally to reproduce the issue
- Fix the issue: Correct the code or configuration based on the error message
- Re-push: Push the code again after fixing
graph LR A[Code commit] --> B[SwiftLint check] B --> C{Passed?} C -->|No| D[Fix issues] D --> A C -->|Yes| E[Unit tests] E --> F{Passed?} F -->|No| D F -->|Yes| G[Build verification] G --> H{Succeeded?} H -->|No| D H -->|Yes| I[Merge code]- Code coverage: Integrate code coverage reporting (e.g., Codecov)
- Performance testing: Add performance benchmarks
- UI testing: Use XCUITest for UI automation testing
- Automated releases: Integrate Fastlane automated release pipeline
- Security scanning: Integrate dependency security scanning tools
- Clean architecture: MVVM + Repository pattern with clear separation of concerns
- Testability: Dependency injection + Protocol abstraction for easy unit testing
- Multi-platform support: One codebase, three platforms, native experience
- Performance optimization: LazyVStack, pagination loading, image caching
- User experience: Dark mode, font settings, browsing history
- Code quality: SwiftLint + CI/CD automation
- UI framework: SwiftUI
- Architecture pattern: MVVM + Repository
- Networking library: Alamofire
- HTML parsing: SwiftSoup
- Dependency management: Swift Package Manager
- Code style: SwiftLint
- CI/CD: GitHub Actions + Fastlane
- Feature completion: Posting, commenting, liking and other interactive features
- Performance optimization: Further optimize list scrolling performance
- Test coverage: Add UI tests and integration tests
- User experience: Push notifications, offline reading, etc.
- Code quality: Improve test coverage, integrate more automation tools
- SwiftUI Official Documentation
- MVVM Architecture Pattern
- Multi-Platform Adaptation Guide
- SwiftLint Documentation
- GitHub Actions Documentation