Skip to content
Tony

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.

Tech , iOS 9 min read

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.

  1. Project Overview
  2. Software Engineering Architecture
  3. SwiftUI Practices and Considerations
  4. Multi-Platform Adaptation Strategy
  5. Code Quality Assurance and CI/CD
  6. 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 --> C

MVVM (Model-View-ViewModel) is the core architecture pattern of the project, with each layer having the following responsibilities:

LayerResponsibilityExample
ViewUI rendering, user interactionHomeView, TopicRowView
ViewModelBusiness logic, state managementTopicListViewModel, FavoritesViewModel
ModelData modelsTopic, Article, Board
RepositoryData access abstractionTopicRepository, MessageRepository
ServiceNetwork requests, business servicesAPIService, BrowsingHistoryStore

Using TopicListViewModel as an example, here is the core MVVM implementation:

App/Modules/Home/ViewModels/TopicListViewModel.swift
@MainActor
final 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:

  1. @MainActor guarantees thread safety: All UI updates execute on the main thread
  2. @Published properties drive the UI: SwiftUI automatically responds to state changes
  3. Dependency injection: Repositories are injected via AppContainer, making testing easier
  4. Error handling: Exceptions are caught and errorMessage is 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
App/Core/Networking/Repositories/TopicRepository.swift
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:

App/Core/Dependency/AppContainer.swift
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.shared ensures a single global instance
  • Lazy initialization: Uses lazy var to 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:

App/Core/Pagination/PaginationState.swift
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 & Hashable type
  • Encapsulated state: Prevents external direct state modification
  • Duplicate load prevention: Uses isLoadingPage flag 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 : injects

SwiftUI, 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:

App/Modules/Home/HomeView.swift
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 WrapperPurposeUse Case
@StateView-internal stateTemporary UI state (e.g., selected item)
@StateObjectView-owned ObservableObjectViewModel lifecycle tied to view
@ObservedObjectExternally passed ObservableObjectShared ViewModel
@EnvironmentObjectEnvironment objectGlobal state (e.g., login status)
@EnvironmentEnvironment valuesSystem settings (e.g., color scheme)

Best practices:

App/Modules/Home/HomeView.swift
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:

App/Core/Utils/AppTheme.swift
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:

App/Modules/Home/HomeView.swift
.onAppear {
Task {
await navigationViewModel.loadNavigationsIfNeeded()
}
}

Considerations:

  • Use Task { } to start async tasks in Views
  • ViewModel methods should be marked as async
  • Use @MainActor to ensure UI updates happen on the main thread

SwiftUI supports platform-specific code using #if os():

App/Components/ImageViewer.swift
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:

App/Modules/Home/TopicRowView.swift
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: .accessibilityLabel added 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.

FeatureiOSmacOS
NavigationTabView bottom navigationNavigationSplitView sidebar
InteractionTouch gesturesMouse + keyboard
Window managementFull-screen appMulti-window support
Image viewerUIKit (smooth gestures)SwiftUI (mouse adapted)
Sheet presentationBottom sheetStandalone window
ToolbarNavigation barMenu bar + toolbar

The project selects different navigation approaches based on the platform in ContentView:

App/ContentView.swift
var body: some View {
Group {
#if os(macOS)
macSidebarLayout
#else
if horizontalSizeClass == .compact {
tabLayout
} else {
sidebarLayout
}
#endif
}
}

iOS implementation (TabView):

App/ContentView.swift
private var tabLayout: some View {
TabView(selection: $selection) {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
// ... other tabs
}
}

macOS implementation (NavigationSplitView):

App/ContentView.swift
#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)
}
}
#endif

Design 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):

App/Components/ImageViewer.swift
#if os(iOS)
private struct ImageViewerUIKit: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> ImagePageViewController {
ImagePageViewController(images: images, initialIndex: initialIndex)
}
}
#endif

macOS (SwiftUI implementation):

App/Components/ImageViewer.swift
#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())
}
}
#endif

Differences explained:

  • iOS: Uses UIPageViewController for smooth swipe transitions with better gesture experience
  • macOS: Uses SwiftUI’s ScrollView + MagnificationGesture, adapted for mouse interaction

iOS (bottom sheet):

App/Modules/Home/HomeView.swift
#if os(iOS)
.sheet(isPresented: $showProfileView) {
ProfileView()
.presentationDetents([.large])
}
#endif

macOS (standalone window):

App/Modules/Home/HomeView.swift
#elseif os(macOS)
.sheet(isPresented: $showProfileView) {
ProfileView()
.frame(minWidth: 600, minHeight: 500)
}
#endif

graph 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:

  1. Conditional compilation: Use #if os() to differentiate platform code
  2. Unified interface: Keep ViewModel and Repository layers platform-agnostic
  3. Platform characteristics: Fully leverage each platform’s native experience
  4. Responsive layout: Use horizontalSizeClass to 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: 25

Configuration highlights:

  • Disabled rules: identifier_name (allows more flexible naming), trailing_whitespace (handled by the editor)
  • Scope: Only checks App and SmthTests directories
  • Line length: Warning at 140 characters, error at 180 characters
  • Complexity control: Function body length and cyclomatic complexity have clear limits

Local checks:

Terminal window
swiftlint --config swiftlint.yml
swiftlint --fix --config swiftlint.yml # Auto-fix

Fastlane integration:

lane :lint do
sh("swiftlint --config swiftlint.yml")
end

The 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 ci

Workflow features:

  1. Trigger conditions: Push to main branches or create a Pull Request
  2. Runtime environment: macOS 14, fixed Xcode 16.2
  3. Cache optimization: Caches Swift Package Manager dependencies
  4. 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
tests
end

Execution order:

  1. Lint check: Runs SwiftLint code style checks
  2. Unit tests: Executes all unit tests

The project uses the XCTest framework for unit testing, with testability achieved through dependency injection:

ViewModel test example:

SmthTests/TopicListViewModelTests.swift
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 StubTopicRepository to mock the data source
  • Async testing: Uses async/await to test asynchronous operations
  • Isolation: Each test is independent and relies on no external state

Current test coverage in the project:

ModuleTest FileCoverage
ViewModelTopicListViewModelTests.swiftPagination loading, initial load
StoreBrowsingHistoryStoreTests.swiftBrowsing history, deduplication
SettingsFontSettingsTests.swiftFont 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:

Terminal window
bundle install
bundle exec fastlane ci

  • Run swiftlint to ensure code style compliance
  • Run unit tests to ensure functionality
  • Check for compiler warnings
  • Ensure all tests pass

When CI fails:

  1. Check the logs: GitHub Actions displays detailed error information
  2. Reproduce locally: Run the same commands locally to reproduce the issue
  3. Fix the issue: Correct the code or configuration based on the error message
  4. 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]

  1. Code coverage: Integrate code coverage reporting (e.g., Codecov)
  2. Performance testing: Add performance benchmarks
  3. UI testing: Use XCUITest for UI automation testing
  4. Automated releases: Integrate Fastlane automated release pipeline
  5. Security scanning: Integrate dependency security scanning tools

  1. Clean architecture: MVVM + Repository pattern with clear separation of concerns
  2. Testability: Dependency injection + Protocol abstraction for easy unit testing
  3. Multi-platform support: One codebase, three platforms, native experience
  4. Performance optimization: LazyVStack, pagination loading, image caching
  5. User experience: Dark mode, font settings, browsing history
  6. 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

  1. Feature completion: Posting, commenting, liking and other interactive features
  2. Performance optimization: Further optimize list scrolling performance
  3. Test coverage: Add UI tests and integration tests
  4. User experience: Push notifications, offline reading, etc.
  5. Code quality: Improve test coverage, integrate more automation tools