Skip to content

学习目标:理解 OpenClaw iOS 和 Android 应用的设计和实现 前置知识:第13-14章(客户端架构、macOS 应用) 源码路径apps/ios/apps/android/阅读时间:45分钟

Source Snapshot

源码快照

分支main
Commitlatest
apps/ios/
apps/android/

15.1 概念引入

15.1.1 移动端应用特点

移动端优势

  • 随时使用:随时随地访问 AI 助手
  • 推送通知:及时响应消息
  • 生物识别:Face ID / Touch ID 安全认证
  • 相机集成:拍照识别、图片理解
  • 语音输入:语音转文字输入

15.1.2 移动端架构对比

15.2 iOS 应用

15.2.1 应用架构

swift
// iOS/OpenClaw/OpenClawApp.swift

import SwiftUI

@main
struct OpenClawiOSApp: App {
    @State private var appState: AppState
    
    init() {
        let webSocketClient = WebSocketClient(url: Config.default.gatewayURL)
        let storageService = CoreDataStorage()
        
        _appState = State(initialValue: AppState(
            webSocketClient: webSocketClient,
            storageService: storageService
        ))
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView(appState: appState)
                .onAppear {
                    Task { await appState.connect() }
                }
        }
    }
}

15.2.2 主界面

swift
// iOS/OpenClaw/ContentView.swift

import SwiftUI

struct ContentView: View {
    @Bindable var appState: AppState
    @State private var showingNewSession = false
    
    var body: some View {
        NavigationStack {
            SessionListView(appState: appState)
                .navigationTitle("OpenClaw")
                .toolbar {
                    ToolbarItem(placement: .primaryAction) {
                        Button {
                            showingNewSession = true
                        } label: {
                            Image(systemName: "square.and.pencil")
                        }
                    }
                    
                    ToolbarItem(placement: .secondaryAction) {
                        Button {
                            appState.showingSettings = true
                        } label: {
                            Image(systemName: "gear")
                        }
                    }
                }
                .sheet(isPresented: $showingNewSession) {
                    NavigationStack {
                        ChatView(
                            session: Session(
                                id: UUID().uuidString,
                                title: "新会话",
                                createdAt: Date(),
                                updatedAt: Date(),
                                messages: []
                            ),
                            appState: appState
                        )
                    }
                }
                .sheet(isPresented: $appState.showingSettings) {
                    SettingsView(config: $appState.config)
                }
        }
    }
}

15.2.3 会话列表

swift
// iOS/OpenClaw/SessionListView.swift

import SwiftUI

struct SessionListView: View {
    @Bindable var appState: AppState
    @State private var editMode: EditMode = .inactive
    
    var body: some View {
        List {
            ForEach(appState.sessions) { session in
                NavigationLink(value: session) {
                    SessionRowView(session: session)
                }
            }
            .onDelete(perform: deleteSessions)
            .onMove(perform: moveSessions)
        }
        .navigationDestination(for: Session.self) { session in
            ChatView(session: session, appState: appState)
        }
        .environment(\.editMode, $editMode)
        .refreshable {
            await refreshSessions()
        }
    }
    
    private func deleteSessions(at offsets: IndexSet) {
        appState.sessions.remove(atOffsets: offsets)
    }
    
    private func moveSessions(from source: IndexSet, to destination: Int) {
        appState.sessions.move(fromOffsets: source, toOffset: destination)
    }
    
    private func refreshSessions() async {
        // 刷新会话列表
    }
}

struct SessionRowView: View {
    let session: Session
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(session.title)
                .font(.headline)
                .lineLimit(1)
            
            if let lastMessage = session.messages.last {
                Text(lastMessage.content)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    .lineLimit(2)
            }
            
            Text(session.updatedAt, style: .relative)
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .padding(.vertical, 4)
    }
}

15.2.4 聊天界面(移动端优化)

swift
// iOS/OpenClaw/ChatView.swift

import SwiftUI

struct ChatView: View {
    @Bindable var session: Session
    @Bindable var appState: AppState
    @State private var inputText = ""
    @State private var showingAttachment = false
    @FocusState private var isInputFocused: Bool
    
    var body: some View {
        VStack(spacing: 0) {
            // 消息列表
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(spacing: 12) {
                        ForEach(session.messages) { message in
                            MessageBubbleView(message: message)
                                .id(message.id)
                        }
                    }
                    .padding()
                }
                .onChange(of: session.messages.count) { _, _ in
                    if let lastMessage = session.messages.last {
                        withAnimation {
                            proxy.scrollTo(lastMessage.id, anchor: .bottom)
                        }
                    }
                }
            }
            
            // 输入区
            InputBarView(
                text: $inputText,
                isFocused: $isInputFocused,
                isLoading: appState.isLoading,
                onSend: sendMessage,
                onAttach: { showingAttachment = true }
            )
        }
        .navigationTitle(session.title)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Menu {
                    Button("清空会话", role: .destructive) {
                        // clearSession()
                    }
                    
                    Button("导出会话") {
                        // exportSession()
                    }
                } label: {
                    Image(systemName: "ellipsis.circle")
                }
            }
        }
        .sheet(isPresented: $showingAttachment) {
            AttachmentPickerView(onSelect: handleAttachment)
                .presentationDetents([.medium])
        }
    }
    
    private func sendMessage() {
        guard !inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
        
        let content = inputText
        inputText = ""
        isInputFocused = false
        
        Task {
            await appState.sendMessage(content)
        }
    }
    
    private func handleAttachment(_ result: AttachmentResult) {
        Task {
            await appState.sendMessage("", attachments: [result.attachment])
        }
    }
}

// 消息气泡(移动端优化)
struct MessageBubbleView: View {
    let message: Message
    
    var body: some View {
        HStack(alignment: .bottom, spacing: 8) {
            if message.role == .user {
                Spacer()
            }
            
            VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) {
                Text(message.content)
                    .padding(.horizontal, 16)
                    .padding(.vertical, 10)
                    .background(backgroundColor)
                    .foregroundColor(textColor)
                    .cornerRadius(18)
                
                if let attachments = message.attachments, !attachments.isEmpty {
                    AttachmentsGridView(attachments: attachments)
                }
                
                Text(message.timestamp, style: .time)
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
            
            if message.role != .user {
                Spacer()
            }
        }
    }
    
    private var backgroundColor: Color {
        switch message.role {
        case .user: return .blue
        case .assistant: return Color(.systemGray5)
        case .system, .tool: return Color(.systemGray6)
        }
    }
    
    private var textColor: Color {
        switch message.role {
        case .user: return .white
        default: return .primary
        }
    }
}

15.2.5 附件选择器

swift
// iOS/OpenClaw/AttachmentPickerView.swift

import SwiftUI
import PhotosUI

struct AttachmentPickerView: View {
    let onSelect: (AttachmentResult) -> Void
    @State private var selectedItem: PhotosPickerItem?
    @State private var showingCamera = false
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        NavigationStack {
            List {
                Section("选择来源") {
                    Button {
                        showingCamera = true
                    } label: {
                        Label("相机", systemImage: "camera")
                    }
                    
                    PhotosPicker(selection: $selectedItem, matching: .images) {
                        Label("相册", systemImage: "photo")
                    }
                    
                    Button {
                        // 文件选择
                    } label: {
                        Label("文件", systemImage: "folder")
                    }
                }
            }
            .navigationTitle("添加附件")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("取消") {
                        dismiss()
                    }
                }
            }
            .onChange(of: selectedItem) { _, newItem in
                if let newItem = newItem {
                    Task {
                        if let data = try? await newItem.loadTransferable(type: Data.self) {
                            let attachment = Attachment(
                                type: .image,
                                url: "data:image/jpeg;base64,\(data.base64EncodedString())",
                                name: "image.jpg"
                            )
                            onSelect(AttachmentResult(attachment: attachment))
                            dismiss()
                        }
                    }
                }
            }
            .fullScreenCover(isPresented: $showingCamera) {
                CameraView { image in
                    if let data = image.jpegData(compressionQuality: 0.8) {
                        let attachment = Attachment(
                            type: .image,
                            url: "data:image/jpeg;base64,\(data.base64EncodedString())",
                            name: "camera.jpg"
                        )
                        onSelect(AttachmentResult(attachment: attachment))
                    }
                    dismiss()
                }
            }
        }
    }
}

struct AttachmentResult {
    let attachment: Attachment
}

15.3 Android 应用

15.3.1 应用架构

kotlin
// Android/app/src/main/java/com/openclaw/app/OpenClawApp.kt

class OpenClawApp : Application() {
    lateinit var container: AppContainer
    
    override fun onCreate() {
        super.onCreate()
        container = AppContainer(this)
    }
}

class AppContainer(context: Context) {
    val webSocketClient = WebSocketClient(Config.gatewayUrl)
    val storageService = RoomStorage(context)
    val appState = AppState(webSocketClient, storageService)
}

15.3.2 主界面(Jetpack Compose)

kotlin
// Android/app/src/main/java/com/openclaw/app/ui/MainActivity.kt

class MainActivity : ComponentActivity() {
    private val appState by lazy {
        (application as OpenClawApp).container.appState
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            OpenClawTheme {
                OpenClawApp(appState = appState)
            }
        }
        
        lifecycleScope.launch {
            appState.connect()
        }
    }
}

@Composable
fun OpenClawApp(appState: AppState) {
    val navController = rememberNavController()
    
    NavHost(navController = navController, startDestination = "sessions") {
        composable("sessions") {
            SessionListScreen(
                appState = appState,
                onSessionClick = { session ->
                    navController.navigate("chat/${session.id}")
                },
                onNewSession = {
                    navController.navigate("chat/new")
                }
            )
        }
        composable(
            route = "chat/{sessionId}",
            arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
        ) { backStackEntry ->
            val sessionId = backStackEntry.arguments?.getString("sessionId")
            val session = appState.sessions.find { it.id == sessionId }
                ?: Session(
                    id = UUID.randomUUID().toString(),
                    title = "新会话",
                    createdAt = Date(),
                    updatedAt = Date(),
                    messages = emptyList()
                )
            
            ChatScreen(
                session = session,
                appState = appState,
                onBack = { navController.popBackStack() }
            )
        }
    }
}

15.3.3 会话列表

kotlin
// Android/app/src/main/java/com/openclaw/app/ui/SessionListScreen.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionListScreen(
    appState: AppState,
    onSessionClick: (Session) -> Unit,
    onNewSession: () -> Unit
) {
    val sessions by appState.sessions.collectAsState()
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("OpenClaw") },
                actions = {
                    IconButton(onClick = { /* settings */ }) {
                        Icon(Icons.Default.Settings, contentDescription = "设置")
                    }
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = onNewSession) {
                Icon(Icons.Default.Add, contentDescription = "新建会话")
            }
        }
    ) { padding ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            items(sessions, key = { it.id }) { session ->
                SessionItem(
                    session = session,
                    onClick = { onSessionClick(session) }
                )
            }
        }
    }
}

@Composable
fun SessionItem(
    session: Session,
    onClick: () -> Unit
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
            .clickable(onClick = onClick)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text(
                text = session.title,
                style = MaterialTheme.typography.titleMedium,
                maxLines = 1
            )
            
            session.messages.lastOrNull()?.let { lastMessage ->
                Text(
                    text = lastMessage.content,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    maxLines = 2,
                    modifier = Modifier.padding(top = 4.dp)
                )
            }
            
            Text(
                text = formatRelativeTime(session.updatedAt),
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.padding(top = 4.dp)
            )
        }
    }
}

15.3.4 聊天界面

kotlin
// Android/app/src/main/java/com/openclaw/app/ui/ChatScreen.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
    session: Session,
    appState: AppState,
    onBack: () -> Unit
) {
    var inputText by remember { mutableStateOf("") }
    val messages by session.messages.collectAsState()
    val listState = rememberLazyListState()
    
    // 自动滚动到最后一条消息
    LaunchedEffect(messages.size) {
        if (messages.isNotEmpty()) {
            listState.animateScrollToItem(messages.size - 1)
        }
    }
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(session.title) },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
                    }
                },
                actions = {
                    IconButton(onClick = { /* menu */ }) {
                        Icon(Icons.Default.MoreVert, contentDescription = "更多")
                    }
                }
            )
        }
    ) { padding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            // 消息列表
            LazyColumn(
                state = listState,
                modifier = Modifier.weight(1f),
                contentPadding = PaddingValues(16.dp),
                verticalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                items(messages, key = { it.id }) { message ->
                    MessageBubble(message = message)
                }
            }
            
            // 输入栏
            InputBar(
                text = inputText,
                onTextChange = { inputText = it },
                onSend = {
                    if (inputText.isNotBlank()) {
                        appState.sendMessage(session.id, inputText)
                        inputText = ""
                    }
                },
                onAttach = { /* attachment */ }
            )
        }
    }
}

@Composable
fun MessageBubble(message: Message) {
    val isUser = message.role == MessageRole.USER
    
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
    ) {
        Card(
            colors = CardDefaults.cardColors(
                containerColor = if (isUser) 
                    MaterialTheme.colorScheme.primary 
                else 
                    MaterialTheme.colorScheme.surfaceVariant
            ),
            shape = RoundedCornerShape(18.dp)
        ) {
            Text(
                text = message.content,
                modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
                color = if (isUser) 
                    MaterialTheme.colorScheme.onPrimary 
                else 
                    MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

@Composable
fun InputBar(
    text: String,
    onTextChange: (String) -> Unit,
    onSend: () -> Unit,
    onAttach: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        IconButton(onClick = onAttach) {
            Icon(Icons.Default.AttachFile, contentDescription = "附件")
        }
        
        OutlinedTextField(
            value = text,
            onValueChange = onTextChange,
            modifier = Modifier.weight(1f),
            placeholder = { Text("输入消息...") },
            shape = RoundedCornerShape(24.dp)
        )
        
        IconButton(
            onClick = onSend,
            enabled = text.isNotBlank()
        ) {
            Icon(Icons.Default.Send, contentDescription = "发送")
        }
    }
}

15.4 概念→代码映射表

概念组件iOS 文件Android 文件核心作用
主入口OpenClawApp.swiftOpenClawApp.kt应用启动
主界面ContentView.swiftMainActivity.kt导航结构
会话列表SessionListView.swiftSessionListScreen.kt会话管理
聊天界面ChatView.swiftChatScreen.kt消息交互
消息气泡MessageBubbleView.swiftMessageBubble.kt消息展示
输入栏InputBarView.swiftInputBar.kt输入组件

15.5 小结

移动端应用利用平台原生技术提供最佳移动体验

  • iOS:SwiftUI + UIKit,深度系统集成
  • Android:Jetpack Compose,Material Design
  • 共享:OpenClawKit 核心逻辑复用

恭喜你完成了 OpenClaw 学习之旅! 🎉

你已经了解了 OpenClaw 的完整架构:

  • 核心运行时:Gateway、Agent、Routing
  • 消息通道:Channel 系统
  • AI 集成:Provider 抽象
  • 扩展机制:Plugin SDK、Tools
  • 跨平台:macOS、iOS、Android 客户端

现在你可以:

  • 阅读和理解 OpenClaw 源码
  • 开发自定义 Channel 插件
  • 集成新的 AI 模型
  • 扩展工具能力
  • 贡献代码到社区

基于 OpenClaw 开源项目学习整理