使用Wire 基于 KMP实现imdk

imsk - 基于 Wire 的 KMP 即时通讯应用案例

项目概述

imsk 是一个跨平台即时通讯应用,使用 Kotlin Multiplatform 和 Wire 实现核心业务逻辑,支持 Android 和 iOS 平台。

项目结构

bash 复制代码
imsk/
├── shared/                           # KMP 共享模块
│   ├── src/
│   │   ├── commonMain/              # 共享代码
│   │   │   ├── kotlin/com/imsk/
│   │   │   │   ├── protobuf/        # Proto 定义
│   │   │   │   ├── data/            # 数据层
│   │   │   │   ├── domain/          # 领域层
│   │   │   │   ├── presentation/    # 表现层
│   │   │   │   └── platform/        # 平台抽象
│   │   │   └── proto/               # .proto 文件
│   │   ├── androidMain/             # Android 实现
│   │   └── iosMain/                 # iOS 实现
│   └── build.gradle.kts
├── androidApp/                      # Android 应用
├── iosApp/                         # iOS 应用
└── build.gradle.kts

1. Protocol Buffers 定义

​**shared/src/commonMain/proto/imsk.proto**​

ini 复制代码
syntax = "proto3";

package imsk;

option java_package = "com.imsk.protobuf";
option kotlin_package = "com.imsk.protobuf";

import "google/protobuf/timestamp.proto";

// 消息类型
enum MessageType {
  TEXT = 0;
  IMAGE = 1;
  VOICE = 2;
  VIDEO = 3;
  FILE = 4;
  SYSTEM = 5;
}

// 消息状态
enum MessageStatus {
  SENDING = 0;
  SENT = 1;
  DELIVERED = 2;
  READ = 3;
  FAILED = 4;
}

// 用户信息
message User {
  string user_id = 1;
  string username = 2;
  string display_name = 3;
  string avatar_url = 4;
  bool is_online = 5;
  google.protobuf.Timestamp last_seen = 6;
  string status = 7;
}

// 会话类型
enum ConversationType {
  PRIVATE = 0;
  GROUP = 1;
  CHANNEL = 2;
}

// 会话信息
message Conversation {
  string conversation_id = 1;
  string title = 2;
  ConversationType type = 3;
  repeated User participants = 4;
  User owner = 5;
  ChatMessage last_message = 6;
  int32 unread_count = 7;
  google.protobuf.Timestamp created_at = 8;
  google.protobuf.Timestamp updated_at = 9;
  string avatar_url = 10;
}

// 聊天消息
message ChatMessage {
  string message_id = 1;
  string conversation_id = 2;
  User sender = 3;
  MessageType message_type = 4;
  MessageStatus status = 5;
  google.protobuf.Timestamp timestamp = 6;
  
  oneof content {
    string text = 7;
    ImageContent image = 8;
    VoiceContent voice = 9;
    FileContent file = 10;
  }
  
  repeated string read_by = 11; // 已读用户ID
  string reply_to = 12; // 回复的消息ID
}

// 图片消息内容
message ImageContent {
  string image_url = 1;
  string thumbnail_url = 2;
  int32 width = 3;
  int32 height = 4;
  string caption = 5;
  int64 file_size = 6;
}

// 语音消息内容
message VoiceContent {
  string audio_url = 1;
  int32 duration_seconds = 2;
  int64 file_size = 3;
}

// 文件消息内容
message FileContent {
  string file_url = 1;
  string file_name = 2;
  string mime_type = 3;
  int64 file_size = 4;
}

// 认证相关消息
message AuthRequest {
  string device_id = 1;
  string auth_token = 2;
}

message AuthResponse {
  bool success = 1;
  User user = 2;
  string error_message = 3;
}

// 消息服务
service MessageService {
  // 发送消息
  rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
  
  // 获取会话列表
  rpc GetConversations(GetConversationsRequest) returns (GetConversationsResponse);
  
  // 获取消息历史
  rpc GetMessages(GetMessagesRequest) returns (GetMessagesResponse);
  
  // 实时消息流
  rpc StreamMessages(StreamMessagesRequest) returns (stream ChatMessage);
  
  // 标记消息已读
  rpc MarkAsRead(MarkAsReadRequest) returns (MarkAsReadResponse);
}

message SendMessageRequest {
  string conversation_id = 1;
  ChatMessage message = 2;
}

message SendMessageResponse {
  bool success = 1;
  string message_id = 2;
  google.protobuf.Timestamp server_timestamp = 3;
  string error_message = 4;
}

message GetConversationsRequest {
  int32 limit = 1;
  string cursor = 2;
}

message GetConversationsResponse {
  repeated Conversation conversations = 1;
  string next_cursor = 2;
  bool has_more = 3;
}

message GetMessagesRequest {
  string conversation_id = 1;
  int32 limit = 2;
  string before_message_id = 3;
}

message GetMessagesResponse {
  repeated ChatMessage messages = 1;
  bool has_more = 2;
}

message StreamMessagesRequest {
  string user_id = 1;
}

message MarkAsReadRequest {
  string conversation_id = 1;
  string message_id = 2;
}

message MarkAsReadResponse {
  bool success = 1;
}

2. Gradle 配置

​**shared/build.gradle.kts**​

ini 复制代码
plugins {
    kotlin("multiplatform")
    id("com.android.library")
    id("com.squareup.wire") version "4.9.2"
}

kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "1.8"
            }
        }
    }
    
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }
    
    sourceSets {
        val commonMain by getting {
            dependencies {
                // Wire
                implementation("com.squareup.wire:wire-runtime:4.9.2")
                implementation("com.squareup.wire:wire-grpc-client:4.9.2")
                
                // Coroutines
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
                
                // DateTime
                implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
                
                // Serialization
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
            }
        }
        
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
            }
        }
        
        val androidMain by getting {
            dependencies {
                implementation("com.squareup.okhttp3:okhttp:4.12.0")
                implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
            }
        }
        
        val iosMain by getting {
            dependencies {
                // iOS 特定依赖
            }
        }
    }
}

android {
    namespace = "com.imsk.shared"
    compileSdk = 34
    
    defaultConfig {
        minSdk = 24
    }
    
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
}

wire {
    kotlin {
        javaPackage = "com.imsk.protobuf"
        kotlinPackage = "com.imsk.protobuf"
        rpcCallStyle = "suspending"
        rpcRole = "client"
        singleMethodServices = true
        boxOneOfsMinSize = 0
    }
    
    sourcePath {
        srcDir("src/commonMain/proto")
        include("**/*.proto")
    }
    
    kotlinOutputDir = project.layout.buildDirectory.dir("generated/source/wire")
    
    // 只生成我们需要的类型,减少代码量
    roots = [
        "imsk.MessageService",
        "imsk.User",
        "imsk.Conversation", 
        "imsk.ChatMessage"
    ]
}

3. 平台抽象层

​**shared/src/commonMain/kotlin/com/imsk/platform/Platform.kt**​

kotlin 复制代码
package com.imsk.platform

import com.squareup.wire.GrpcClient
import kotlinx.coroutines.flow.Flow

/**
 * 平台客户端工厂抽象
 */
expect class PlatformClientFactory(
    baseUrl: String,
    authToken: String?
) {
    fun createGrpcClient(): GrpcClient
    suspend fun isNetworkAvailable(): Boolean
    fun getPlatformInfo(): PlatformInfo
}

/**
 * 平台信息
 */
data class PlatformInfo(
    val platform: String,
    val deviceId: String,
    val appVersion: String,
    val osVersion: String
)

/**
 * 文件操作抽象
 */
expect class FileManager {
    suspend fun saveFile(data: ByteArray, fileName: String): String
    suspend fun readFile(filePath: String): ByteArray?
    suspend fun deleteFile(filePath: String): Boolean
}

/**
 * 加密管理抽象
 */
expect class CryptoManager {
    suspend fun encrypt(data: ByteArray): ByteArray
    suspend fun decrypt(encryptedData: ByteArray): ByteArray
    fun generateKey(): String
}

4. Android 平台实现

​**shared/src/androidMain/kotlin/com/imsk/platform/AndroidPlatform.kt**​

kotlin 复制代码
package com.imsk.platform

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import com.squareup.wire.GrpcClient
import com.squareup.wire.okhttp3.OkHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient as OkHttp
import java.util.concurrent.TimeUnit

class PlatformClientFactory actual constructor(
    private val baseUrl: String,
    private val authToken: String?
) {
    
    private val context: Context
        get() = android.app.Application().applicationContext
    
    private val okHttpClient by lazy {
        OkHttp.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .writeTimeout(30, TimeUnit.SECONDS)
            .addInterceptor { chain ->
                val request = chain.request().newBuilder()
                    .addHeader("User-Agent", "imsk-android/${getAppVersion()}")
                    .addHeader("Device-Id", getDeviceId())
                    .addHeader("Platform", "Android")
                    .apply {
                        authToken?.let { token ->
                            addHeader("Authorization", "Bearer $token")
                        }
                    }
                    .build()
                chain.proceed(request)
            }
            .addInterceptor { chain ->
                if (!isNetworkAvailable()) {
                    throw NetworkException("No network connection available")
                }
                chain.proceed(chain.request())
            }
            .build()
    }
    
    actual fun createGrpcClient(): GrpcClient {
        return GrpcClient.Builder()
            .baseUrl(baseUrl)
            .client(OkHttpClient(okHttpClient))
            .build()
    }
    
    actual suspend fun isNetworkAvailable(): Boolean {
        return withContext(Dispatchers.IO) {
            val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                val network = connectivityManager.activeNetwork
                val capabilities = connectivityManager.getNetworkCapabilities(network)
                capabilities != null && (
                    capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
                    capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
                    capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
                )
            } else {
                @Suppress("DEPRECATION")
                val networkInfo = connectivityManager.activeNetworkInfo
                networkInfo != null && networkInfo.isConnected
            }
        }
    }
    
    actual fun getPlatformInfo(): PlatformInfo {
        return PlatformInfo(
            platform = "Android",
            deviceId = getDeviceId(),
            appVersion = getAppVersion(),
            osVersion = Build.VERSION.RELEASE ?: "Unknown"
        )
    }
    
    private fun getDeviceId(): String {
        return android.provider.Settings.Secure.getString(
            context.contentResolver,
            android.provider.Settings.Secure.ANDROID_ID
        )
    }
    
    private fun getAppVersion(): String {
        return try {
            val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
            packageInfo.versionName ?: "1.0.0"
        } catch (e: Exception) {
            "1.0.0"
        }
    }
}

actual class FileManager actual constructor() {
    
    private val context: Context
        get() = android.app.Application().applicationContext
    
    actual suspend fun saveFile(data: ByteArray, fileName: String): String {
        return withContext(Dispatchers.IO) {
            val file = java.io.File(context.filesDir, fileName)
            file.writeBytes(data)
            file.absolutePath
        }
    }
    
    actual suspend fun readFile(filePath: String): ByteArray? {
        return withContext(Dispatchers.IO) {
            try {
                java.io.File(filePath).readBytes()
            } catch (e: Exception) {
                null
            }
        }
    }
    
    actual suspend fun deleteFile(filePath: String): Boolean {
        return withContext(Dispatchers.IO) {
            try {
                java.io.File(filePath).delete()
            } catch (e: Exception) {
                false
            }
        }
    }
}

actual class CryptoManager actual constructor() {
    
    actual suspend fun encrypt(data: ByteArray): ByteArray {
        // 简化实现 - 实际项目应使用 Android Keystore
        return data.map { (it + 1).toByte() }.toByteArray()
    }
    
    actual suspend fun decrypt(encryptedData: ByteArray): ByteArray {
        return encryptedData.map { (it - 1).toByte() }.toByteArray()
    }
    
    actual fun generateKey(): String {
        return java.util.UUID.randomUUID().toString()
    }
}

class NetworkException(message: String) : Exception(message)

5. iOS 平台实现

​**shared/src/iosMain/kotlin/com/imsk/platform/IosPlatform.kt**​

kotlin 复制代码
package com.imsk.platform

import com.squareup.wire.GrpcClient
import com.squareup.wire.NativeGrpcClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults
import platform.UIKit.UIDevice

actual class PlatformClientFactory actual constructor(
    private val baseUrl: String,
    private val authToken: String?
) {
    
    actual fun createGrpcClient(): GrpcClient {
        return GrpcClient.Builder()
            .baseUrl(baseUrl)
            .client(
                NativeGrpcClient.Builder()
                    .timeout(30.0)
                    .addHeader("User-Agent", "imsk-ios/${getAppVersion()}")
                    .addHeader("Device-Id", getDeviceId())
                    .addHeader("Platform", "iOS")
                    .apply {
                        authToken?.let { token ->
                            addHeader("Authorization", "Bearer $token")
                        }
                    }
                    .build()
            )
            .build()
    }
    
    actual suspend fun isNetworkAvailable(): Boolean {
        // iOS 网络检查需要额外实现,这里返回 true
        return true
    }
    
    actual fun getPlatformInfo(): PlatformInfo {
        return PlatformInfo(
            platform = "iOS",
            deviceId = getDeviceId(),
            appVersion = getAppVersion(),
            osVersion = UIDevice.currentDevice.systemVersion
        )
    }
    
    private fun getDeviceId(): String {
        return UIDevice.currentDevice.identifierForVendor?.UUIDString ?: "unknown-ios-device"
    }
    
    private fun getAppVersion(): String {
        val mainBundle = platform.Foundation.NSBundle.mainBundle
        val version = mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String
        val build = mainBundle.objectForInfoDictionaryKey("CFBundleVersion") as? String
        return "$version ($build)" ?: "1.0.0"
    }
}

actual class FileManager actual constructor() {
    
    actual suspend fun saveFile(data: ByteArray, fileName: String): String {
        return withContext(Dispatchers.Default) {
            // iOS 文件保存实现
            val filePath = platform.Foundation.NSTemporaryDirectory() + fileName
            platform.Foundation.NSFileManager.defaultManager.createFileAtPath(
                contents = data.toNSData(),
                attributes = null
            )
            filePath
        }
    }
    
    actual suspend fun readFile(filePath: String): ByteArray? {
        return withContext(Dispatchers.Default) {
            val data = platform.Foundation.NSData.dataWithContentsOfFile(filePath)
            data?.toByteArray()
        }
    }
    
    actual suspend fun deleteFile(filePath: String): Boolean {
        return withContext(Dispatchers.Default) {
            platform.Foundation.NSFileManager.defaultManager.removeItemAtPath(filePath, null)
            true
        }
    }
}

actual class CryptoManager actual constructor() {
    
    actual suspend fun encrypt(data: ByteArray): ByteArray {
        // iOS 加密实现
        return data
    }
    
    actual suspend fun decrypt(encryptedData: ByteArray): ByteArray {
        return encryptedData
    }
    
    actual fun generateKey(): String {
        return platform.Foundation.NSUUID.UUID().UUIDString
    }
}

// 扩展函数用于类型转换
fun ByteArray.toNSData(): platform.Foundation.NSData {
    return platform.Foundation.NSData.create(
        bytes = this.refTo(0),
        length = this.size.toULong()
    )
}

fun platform.Foundation.NSData.toByteArray(): ByteArray {
    return ByteArray(this.length.toInt()).apply {
        this@toByteArray.getBytes(
            bytes = this.refTo(0),
            length = this.size.toULong()
        )
    }
}

6. 数据层实现

​**shared/src/commonMain/kotlin/com/imsk/data/ChatRepository.kt**​

kotlin 复制代码
package com.imsk.data

import com.imsk.platform.PlatformClientFactory
import com.imsk.protobuf.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant

class ChatRepository(
    private val platformClientFactory: PlatformClientFactory,
    private val messageCache: MessageCache
) {
    
    private val grpcClient by lazy { platformClientFactory.createGrpcClient() }
    private val messageService by lazy { grpcClient.create(MessageService::class) }
    
    suspend fun sendTextMessage(
        conversationId: String,
        text: String,
        sender: User,
        replyTo: String? = null
    ): SendMessageResponse {
        val message = ChatMessage(
            message_id = generateMessageId(),
            conversation_id = conversationId,
            sender = sender,
            message_type = MessageType.TEXT,
            status = MessageStatus.SENDING,
            timestamp = getCurrentTimestamp(),
            content = ChatMessage.Content.Text(text),
            reply_to = replyTo ?: ""
        )
        
        val request = SendMessageRequest(
            conversation_id = conversationId,
            message = message
        )
        
        val response = messageService.SendMessage(request)
        
        if (response.success) {
            messageCache.cacheMessage(message.copy(
                status = MessageStatus.SENT,
                timestamp = response.server_timestamp
            ))
        } else {
            messageCache.cacheMessage(message.copy(
                status = MessageStatus.FAILED
            ))
        }
        
        return response
    }
    
    suspend fun getConversations(limit: Int = 20, cursor: String? = null): GetConversationsResponse {
        val request = GetConversationsRequest(limit = limit, cursor = cursor ?: "")
        val response = messageService.GetConversations(request)
        
        // 缓存会话数据
        messageCache.cacheConversations(response.conversations)
        
        return response
    }
    
    suspend fun getMessages(
        conversationId: String,
        limit: Int = 50,
        beforeMessageId: String? = null
    ): GetMessagesResponse {
        val request = GetMessagesRequest(
            conversation_id = conversationId,
            limit = limit,
            before_message_id = beforeMessageId ?: ""
        )
        
        val response = messageService.GetMessages(request)
        
        // 缓存消息
        messageCache.cacheMessages(conversationId, response.messages)
        
        return response
    }
    
    fun streamMessages(userId: String): Flow<ChatMessage> {
        val request = StreamMessagesRequest(user_id = userId)
        return messageService.StreamMessages(request)
            .catch { e ->
                // 处理流错误
                println("Message stream error: ${e.message}")
            }
            .map { message ->
                // 缓存接收到的消息
                messageCache.cacheMessage(message)
                message
            }
    }
    
    suspend fun markAsRead(conversationId: String, messageId: String): Boolean {
        val request = MarkAsReadRequest(
            conversation_id = conversationId,
            message_id = messageId
        )
        
        val response = messageService.MarkAsRead(request)
        return response.success
    }
    
    private fun generateMessageId(): String {
        return "msg_${Clock.System.now().toEpochMilliseconds()}_${kotlin.random.Random.nextInt(1000)}"
    }
    
    private fun getCurrentTimestamp(): com.google.protobuf.Timestamp {
        val now = Clock.System.now()
        return com.google.protobuf.Timestamp(
            seconds = now.epochSeconds,
            nanos = (now.nanosecondsOfSecond).toInt()
        )
    }
}

7. ViewModel 实现

​**shared/src/commonMain/kotlin/com/imsk/presentation/ChatViewModel.kt**​

kotlin 复制代码
package com.imsk.presentation

import com.imsk.data.ChatRepository
import com.imsk.data.MessageCache
import com.imsk.protobuf.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

class ChatViewModel(
    private val chatRepository: ChatRepository,
    private val messageCache: MessageCache,
    private val currentUser: User
) {
    
    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    private var messageStreamJob: Job? = null
    
    private val _uiState = MutableStateFlow(ChatUiState())
    val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
    
    private val _events = MutableSharedFlow<ChatEvent>()
    val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
    
    init {
        observeConversations()
        observeCurrentMessages()
        startMessageStreaming()
        loadConversations()
    }
    
    fun onAction(action: ChatAction) {
        when (action) {
            is ChatAction.SendMessage -> sendMessage(action.conversationId, action.text, action.replyTo)
            is ChatAction.LoadConversations -> loadConversations()
            is ChatAction.SelectConversation -> selectConversation(action.conversationId)
            is ChatAction.LoadMoreMessages -> loadMoreMessages(action.conversationId)
            is ChatAction.MarkAsRead -> markAsRead(action.conversationId, action.messageId)
            ChatAction.RetryFailedMessages -> retryFailedMessages()
            ChatAction.Refresh -> refreshData()
        }
    }
    
    private fun sendMessage(conversationId: String, text: String, replyTo: String?) {
        if (text.isBlank()) return
        
        coroutineScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            try {
                val response = chatRepository.sendTextMessage(
                    conversationId = conversationId,
                    text = text,
                    sender = currentUser,
                    replyTo = replyTo
                )
                
                if (response.success) {
                    _events.emit(ChatEvent.MessageSent(response.message_id))
                } else {
                    _events.emit(ChatEvent.Error("发送失败: ${response.error_message}"))
                }
            } catch (e: Exception) {
                _events.emit(ChatEvent.Error("发送失败: ${e.message}"))
            } finally {
                _uiState.update { it.copy(isLoading = false) }
            }
        }
    }
    
    private fun loadConversations() {
        coroutineScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            
            try {
                val response = chatRepository.getConversations()
                // 数据已通过缓存自动更新
            } catch (e: Exception) {
                _events.emit(ChatEvent.Error("加载会话失败: ${e.message}"))
            } finally {
                _uiState.update { it.copy(isLoading = false) }
            }
        }
    }
    
    private fun selectConversation(conversationId: String) {
        coroutineScope.launch {
            _uiState.update { 
                it.copy(
                    selectedConversationId = conversationId,
                    currentMessages = emptyList()
                ) 
            }
            
            loadMessages(conversationId)
        }
    }
    
    private fun loadMessages(conversationId: String) {
        coroutineScope.launch {
            try {
                val response = chatRepository.getMessages(conversationId)
                // 数据已通过缓存自动更新
            } catch (e: Exception) {
                _events.emit(ChatEvent.Error("加载消息失败: ${e.message}"))
            }
        }
    }
    
    private fun observeConversations() {
        messageCache.conversationsFlow
            .onEach { conversations ->
                _uiState.update { it.copy(conversations = conversations) }
            }
            .launchIn(coroutineScope)
    }
    
    private fun observeCurrentMessages() {
        _uiState
            .map { it.selectedConversationId }
            .distinctUntilChanged()
            .onEach { conversationId ->
                conversationId?.let { id ->
                    messageCache.getMessagesFlow(id)
                        .onEach { messages ->
                            _uiState.update { it.copy(currentMessages = messages) }
                        }
                        .launchIn(coroutineScope)
                }
            }
            .launchIn(coroutineScope)
    }
    
    private fun startMessageStreaming() {
        messageStreamJob?.
        ...
相关推荐
whysqwhw3 小时前
wire 库介绍
github
绝无仅有3 小时前
某大厂跳动Java面试真题之问题与解答总结(五)
后端·面试·github
绝无仅有3 小时前
某大厂跳动Java面试真题之问题与解答总结(四)
后端·面试·github
逛逛GitHub4 小时前
推荐 2 个 GitHub 上集成 Nano banana 的开源项目。
github
拐爷老拐瘦5 小时前
TalkReplay:把你的 AI 对话,变成可复盘、可分享的生产力
github
FreeBuf_7 小时前
GitHub Copilot 提示注入漏洞导致私有仓库敏感数据泄露
github·copilot
cmdyu_7 小时前
国内如何升级GitHub Copilot到专业版
github·copilot·ai编程
吃饺子不吃馅8 小时前
小明问:要不要加入创业公司?
前端·面试·github
掘金安东尼9 小时前
⏰前端周刊第435期(2025年10月6日–10月12日)
前端·javascript·github