安卓开发中OkHttp的使用——基于Kotlin
GET请求、POST请求、图片下载、文件的下载上传
目录
在使用OkHttp之前,需要在项目添加OkHttp的依赖,在dependencies闭包中添加:
dependencies {
...
implementation("com.squareup.okhttp3:okhttp:x.x.x")
...
}
一、发起GET请求
①创建OkHttpClient的实例
val client = OkHttpClient()
②创建Request对象(空的)
val request = Request.Builder().build()
③添加内容
在最后的build()方法之前通过连缀来添加内容
val request = Request.Builder()
.url("https://www.csdn.net/")
.build()
④创建Call对象
调用OkHttpClient的newCall方法来创建Call对象,并且调用execute()方法发送请求并获取服务器的数据
在OkHttp库中,Call是一个核心接口,代表一个HTTP请求的调用。当你构建一个请求并准备执行时,OkHttp会为你创建一个Call实例。这个对象是可执行的,意味着你可以通过它来执行实际的HTTP请求,并获取响应。
val response = client.newCall(request).execute() //执行网络请求,获取响应
完整GET请求代码:
private fun sendRequestWithOkHttp(){
thread {
try {
val client = OkHttpClient() //获取OkHttpClient实例
val request = Request.Builder() //创建request对象
.url("https://www.csdn.net/")
.build()
//执行网络请求,获取响应
val response = client.newCall(request).execute()
//将获取到的数据转换为字符串格式
val responseData = response.body?.string()
if (responseData!=null){
showResponse(responseData)
}
}catch (e: Exception){
e.printStackTrace()
}
}
}
二、发起POST请求
发起POST请求相对GET请求就是多出来的部分就是需要准备需要发送的数据,这个数据可能是表单数据,也可能是JSON数据
①准备数据
需要构建一个RequestBody对象存放待提交的数据
(1)表单数据
val formBody = FormBody.Builder()
.add("参数名1", "参数值1")
.add("参数名2", "参数值2")
.build()
(2)JSON数据
val json = "{\"key1\":\"value1\",\"key2\":\"value2\"}"
val jsonBody =RequestBody.create(json, MediaType.parse("application/json; charset=utf-8"))
//调用 MediaType.parse() 方法并传入这个字符串时,
//OkHttp 会解析这个字符串并创建一个 MediaType 对象,该对象包含了上述信息。
//这个 MediaType 对象通常被用作 RequestBody 的参数,告诉服务器客户端发送的数据是 JSON 格式的,使用的是 UTF-8 编码。
②调用POST方法
在Request.Builder中调用post()方法,然后把RequestBody对象传入
val request = Request.Builder()
.url("http://服务器地址")
.post(formBody) // 或者 jsonBody
.build()
完整POST请求代码:
private fun sendRequestWithOkHttp(){
thread {
try {
val client = OkHttpClient() //获取OkHttpClient实例
val formBody = FormBody.Builder() //待提交的表单数据
.add("参数名1", "参数值1")
.add("参数名2", "参数值2")
.build()
val request = Request.Builder() //创建request对象
.url("https://www.csdn.net/")
.post(formBody) //调用post方法,将RequestBody对象传入
.build()
val response = client.newCall(request).execute() //执行网络请求,获取响应
val responseData = response.body?.string()
if (responseData!=null){
showResponse(responseData)
}
}catch (e: Exception){
e.printStackTrace()
}
}
}
三、下载图片(未加载进度版)
①前提
下载图片需要用到网络资源和存储设备资源,所以要先在AndroidManifest文件中加入以下代码:
<!-- 使用网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 使用存储空间 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
②定义布局文件
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/btn_download_image"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="下载图片"
android:textColor="@color/black"
android:textSize="17sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:textColor="@color/black"
android:textSize="17sp" />
<TextView
android:id="@+id/tv_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:textColor="@color/black"
android:textSize="17sp" />
<ImageView
android:id="@+id/iv_result"
android:layout_width="match_parent"
android:layout_height="250dp" />
</LinearLayout>
③ 编写MainActivity文件
完成对应的MainActivity文件
package com.example.loadingfilewithokhttp
import android.annotation.SuppressLint
import android.graphics.BitmapFactory
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
class MainActivity : AppCompatActivity() {
private lateinit var buttonDownload: Button
private lateinit var textViewResult: TextView
private lateinit var textViewProgress: TextView
private lateinit var imageViewResult: ImageView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
buttonDownload = findViewById(R.id.btn_download_image)
textViewResult = findViewById(R.id.tv_result)
textViewProgress = findViewById(R.id.tv_progress)
imageViewResult = findViewById(R.id.iv_result)
buttonDownload.setOnClickListener {
val imageUrl =
"https://img.alicdn.com/i4/2206671953827/O1CN01pVAjpj1e8oPpjCF6f_!!2206671953827.jpg"
downloadImage(imageUrl)
}
}
private fun downloadImage(imageUrl: String) {
val client = OkHttpClient()
val request = Request.Builder()
.url(imageUrl)
.build()
// 异步执行网络请求,将请求加入到请求队列中
client.newCall(request).enqueue(object : Callback {
// 使用@SuppressLint注解来抑制警告,这里用于忽略国际化相关的警告
@SuppressLint("SetTextI18n")
// 请求失败时的回调,打印异常堆栈信息,并更新UI显示错误信息
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
textViewResult.text = "下载失败:${e.message}"
}
@SuppressLint("SetTextI18n")
// 请求成功时的回调
override fun onResponse(call: Call, response: Response) {
// 如果响应不是成功的,更新UI显示请求失败的状态码
if (!response.isSuccessful) {
textViewResult.text = "请求失败:${response.code}"
return
}
// 尝试获取响应的输入流
val body = response.body?.byteStream()
//通过 use 函数来自动管理,响应体 response.body() 被关闭以释放资源
body?.use { inputStream ->
// 使用BitmapFactory从输入流中解码图片
val bitmap = BitmapFactory.decodeStream(inputStream)
// 使用Handler在主线程更新UI,因为UI操作必须在主线程中执行
Handler(Looper.getMainLooper()).post {
// 设置ImageView显示下载的图片
imageViewResult.setImageBitmap(bitmap)
//更新UI界面的下载结果
textViewResult.text = "图片下载成功!"
//原本是显示下载进度的,还没学完,先展示响应的状态码
textViewProgress.text = "状态码:${response.code}"
}
}
}
})
}
}
关于上述代码的补充:
- Looper.getMainLooper()是 Android 开发中的一个常用方法,它用于获取与应用程序的主线程(UI 线程)关联的Looper对象
- Handler 与 Looper 通常用于跨线程通信。在 Android 中,主线程(也称为 UI 线程)负责处理所有 UI 相关的操作。由于网络请求通常是在后台线程中完成的,因此需要使用 Handler 将结果传递回主线程
④运行结果:
成功下载图片并展示到ImageView控件中:
四、下载图片(加载进度版)
①定义ProgressResponseBody类
加载进度版本的布局和未加载的相同
定义ProgressResponseBody类,实现监听进度:
package com.example.loadingfilewithokhttp
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.IOException
import okio.buffer
// 自定义的 ProgressResponseBody 类,继承自 ResponseBody 用于监听数据读取进度
class ProgressResponseBody(
private val responseBody: ResponseBody, // 原始响应体
private val progressListener: ProgressListener // 进度监听器
) : ResponseBody() {
// 定义进度监听接口
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}
// 实现 contentType() 方法,返回响应体的媒体类型
override fun contentType(): MediaType? = responseBody.contentType()
// 实现 contentLength() 方法,返回响应体的内容长度
override fun contentLength(): Long = responseBody.contentLength()
// 实现 source() 方法,返回一个包装后的 BufferedSource 对象
override fun source(): BufferedSource {
// 创建 ForwardingSource 包装原始响应体的 source
return object : ForwardingSource(responseBody.source()) {
// 记录已读取的字节数
var totalBytesRead = 0L
@Throws(IOException::class)
// 重写 read() 方法,以便在读取时更新进度
override fun read(sink: Buffer, byteCount: Long): Long {
// 调用父类的 read() 方法
val bytesRead = super.read(sink, byteCount)
if (bytesRead != -1L) {
// 更新已读取的字节数
totalBytesRead += bytesRead
// 调用进度监听器的 update() 方法
progressListener.update(totalBytesRead, contentLength(), bytesRead == -1L)
}
return bytesRead
}
}.buffer()// 将 ForwardingSource 包装为 BufferedSource
}
}
关于上述代码的补充:
在 ProgressResponseBody 类中,ForwardingSource 类的 read() 方法重写自 OkHttp 库的 Source 接口。Source 接口是一个用于读取网络数据的抽象类,它提供了从底层网络连接读取字节流的基本方法。
ForwardingSource 类是 Okio 库中的一个包装类,它委托给另一个 Source 对象,并允许拦截和包装对原始 Source 的调用。可以在不修改原始 Source 类的情况下,添加额外的功能,比如跟踪读取进度。
在 ProgressResponseBody 的 read() 方法中,父类(即 ForwardingSource)的 read() 方法实现了以下功能:
-
读取数据:从底层的 Source(即网络连接)读取数据。这是通过调用 super.read(sink, byteCount) 完成的,它将数据从 Source 读取到提供的 Buffer 对象中。
-
跟踪进度:每当数据被读取时,read() 方法会更新已读取的字节总数,并根据这个总数和响应体的总长度(contentLength())计算出当前的进度。
-
回调监听器:如果读取操作成功(即 bytesRead 不等于 -1),它会调用 progressListener 的 update() 方法,并将当前的进度、总长度和是否完成(done)作为参数传递。
-
处理读取完成:如果 read() 方法返回 -1,表示数据读取完成(即到达了数据流的末尾),此时 progressListener 的 update() 方法会被调用,并将 done 参数设置为 true。
②更新MainAtivity文件
package com.example.loadingfilewithokhttp
import android.annotation.SuppressLint
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
class MainActivity : AppCompatActivity() {
private lateinit var btnDownloadImage: Button
private lateinit var tvResult: TextView
private lateinit var tvProgress: TextView
private lateinit var ivResult: ImageView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) // 替换为你的布局ID
btnDownloadImage = findViewById(R.id.btn_download_image)
tvResult = findViewById(R.id.tv_result)
tvProgress = findViewById(R.id.tv_progress)
ivResult = findViewById(R.id.iv_result)
btnDownloadImage.setOnClickListener {
// val imageUrl = "https://img14.360buyimg.com/pop/jfs/t1/221018/31/14201/124651/624417bcEd3c74e11/2e404360191f25a0.jpg"
val imageUrl =
"https://pic1.zhimg.com/v2-cb18b9cf866abb969ecda8f275701d37_r.jpg?source=1940ef5c"
downloadImage(imageUrl)
}
}
private fun downloadImage(url: String) {
// 创建OkHttpClient实例
val client = OkHttpClient()
// 创建请求构建器
val request = Request.Builder()
.url(url) // 设置请求的URL
.build() // 构建请求对象
// 异步执行网络请求
client.newCall(request).enqueue(object : okhttp3.Callback {
@SuppressLint("SetTextI18n")
// 网络请求失败时调用
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
tvResult.text = "下载失败: ${e.message}"
}
// 网络请求成功时调用
override fun onResponse(call: Call, response: Response) {
val responseBody = response.body
if (responseBody != null) {
// 创建进度监听器
val progressListener = object : ProgressResponseBody.ProgressListener {
@SuppressLint("SetTextI18n")
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
val progress = (100f * bytesRead / contentLength).toInt()
// 显示下载进度
tvProgress.text = "下载进度: ${progress}%"
if (done) {
tvProgress.text = "下载完成"
}
}
}
// 使用 ProgressResponseBody 包装响应体,以便监听进度
val progressBody = ProgressResponseBody(responseBody, progressListener)
// 获取字节流
val byteStream = progressBody.byteStream()
// 将字节流解码为 Bitmap
val bitmap = BitmapFactory.decodeStream(byteStream)
// 在主线程更新 UI,设置图片
runOnUiThread {
ivResult.setImageBitmap(bitmap)
tvResult.text = "图片下载成功"
}
} else {
tvResult.text = "没有响应体"
}
}
})
}
}
③运行结果:
成功下载图片并展示下载进度:
五、下载文件(加载进度)
前提:确保应用具有访问网络以及写入外部存储或内部私有目录的权限。对于Android 6.0(API级别23)及以上,需要在运行时请求这些权限。
...
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
...
使用OkHttp下载图片和文件的步骤很相似,但它们在处理响应体时有所不同。
两者在网络请求和响应处理方面有许多相似之处,在使用OkHttp时,可以重用相同的请求构建和发送逻辑,只是对响应体的处理和UI更新部分有所不同。
①定义布局文件
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- 进度条 -->
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="false"
android:progress="0" />
<!-- 用于显示下载状态的文本视图 -->
<TextView
android:id="@+id/statusTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:textSize="26sp"
android:text="@string/ready_to_download"
android:textAppearance="?android:attr/textAppearanceMedium" />
<!-- 下载按钮 -->
<Button
android:id="@+id/downloadButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="@string/download_file" />
</LinearLayout>
②编辑MainActivity文件
package com.example.downloadfilewithokhttp
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Environment
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
class MainActivity : AppCompatActivity() {
private val URL = "https://ptgl.fujian.gov.cn:8088/masvod/public/2021/03/19/20210319_178498bcae9_r38.mp4"
private lateinit var progressBar: ProgressBar
private lateinit var downloadButton: Button
private lateinit var statusTextView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
progressBar = findViewById(R.id.progressBar)
downloadButton = findViewById(R.id.downloadButton)
statusTextView = findViewById(R.id.statusTextView)
downloadButton.setOnClickListener{
downloadFile()
}
}
private fun downloadFile(){
// 创建OkHttpClient实例,用于发起网络请求
val client = OkHttpClient()
// 使用Request.Builder构建HTTP请求
val request = Request.Builder()
// 设置请求的URL
.url(URL)
// 构建请求对象
.build()
// 异步执行请求,并传入回调对象处理响应或失败情况
client.newCall(request).enqueue(object : Callback{
// 失败回调,当请求遇到错误时被调用
@SuppressLint("SetTextI18n") // 忽略设置文本的国际化警告
override fun onFailure(call: Call, e: IOException) {
// 在主线程更新UI
runOnUiThread{
// 设置状态文本为下载失败
statusTextView.text = "Download File failed!"
}
}
// 成功回调,当请求成功并收到响应时被调用
@SuppressLint("SetTextI18n") // 同上,忽略国际化警告
override fun onResponse(call: Call, response: Response) {
// 检查响应是否成功(HTTP状态码为200系列)
if(response.isSuccessful){
// 安全地获取响应体的内容长度,如果无法获取(例如body不存在),则默认赋值为0
val totalSize = response.body?.contentLength() ?: 0
// 初始化已下载大小为0
var downloadSize = 0L
// 准备存储文件的位置和名称
val file = File(getExternalFilesDir(Environment.DIRECTORY_MOVIES), "myMovie.mp4")
// 创建文件输出流,准备写入文件
val outputStream = FileOutputStream(file)
// 尝试从响应体读取数据
response.body?.let { body ->
// 使用输入流读取响应体的数据
body.byteStream().use { inputStream ->
// 定义缓冲区
val buffer = ByteArray(2048)
var len: Int
// 循环读取直到没有更多数据(-1表示EOF)
while (inputStream.read(buffer).also { len = it } != -1){
// 写入缓冲区数据到文件
outputStream.write(buffer, 0, len)
// 累加已下载的字节数
downloadSize += len
// 更新进度条,需在主线程执行
runOnUiThread{
// 计算当前进度并设置到进度条
val progress = (downloadSize.toFloat() / totalSize * 100).toInt()
progressBar.progress = progress
}
}
}
// 关闭输出流,释放资源
outputStream.close()
}
// 下载完成,更新状态文本
runOnUiThread{
statusTextView.text = "Download File completed"
}
} else {
// 响应非成功时,显示错误状态码
runOnUiThread{
statusTextView.text = "Download failed with status code:${response.code}"
}
}
}
})
}
}
补充:
while (inputStream.read(buffer).also { len = it } != -1)
inputStream.read(buffer)
: 这部分尝试从inputStream
中读取数据并存储到buffer
中。read(buffer)
方法会将数据读入缓冲区,并返回实际读取的字节数。如果达到文件或流的末尾,它会返回-1。
.also { len = it }
: Kotlin中的also
的函数,它接受一个lambda表达式作为参数,并在执行完主体操作后运行这个lambda。在这里,it
是read(buffer)
的返回值,即实际读取的字节数。这个表达式将实际读取的字节数赋值给变量len
。使用also
的好处是可以一边执行操作(读取数据),一边处理副作用(更新len
的值),而不需要先将结果存入临时变量。
③运行结果
④查看下载文件
使用文件浏览器:
- 直接在IDE内通过工具找到文件。转到
Tools
->Device File Explorer
,这会打开一个文件资源管理器窗口。 - 在设备文件资源管理器中,依次展开路径:
/sdcsrd/android/data/data/包名/files/Download/
。这里的包名应用程序的名称,例如我的应用名称:downloadfilewithokhttp - 在Download目录下,就能看到名为myMovie.mp4的文件。
六、上传文件
上传文件和下载文件的主要区别在于准备要上传文件的RequestBody上:
-
创建文件的RequestBody:
fileBody = RequestBody.create(MediaType.parse("multipart/form-data"), file)
这行代码创建了一个RequestBody实例,它代表了要上传的文件内容。MediaType.parse("multipart/form-data")指定了内容类型为multipart/form-data,这是HTTP中用于文件上传的标准格式。file参数是要上传的本地文件实例。
-
获取文件名:
fileName = file.name
这行代码从File对象中提取出文件名。
-
构建MultipartBody.Part:
filePart = MultipartBody.Part.createFormData("file", fileName, fileBody)
这行代码利用 MultipartBody.Part.createFormData 方法创建了一个表单项(part),它表示了文件上传的部分。参数包括:
- "file":这是表单字段的名称,服务器端用来识别这是一个文件上传字段。需要根据服务器API的要求来设定这个名称。
- fileName:这是上一步获取到的文件名,通常服务器端会用它来保存文件或识别文件。
- fileBody:这是之前创建的包含文件内容的RequestBody。
import okhttp3.MediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.Call
import okhttp3.Callback
import java.io.File
fun uploadFileAsync(file: File) {
val client = OkHttpClient()
// 准备要上传的文件的RequestBody
val fileBody = RequestBody.create(MediaType.parse("multipart/form-data"), file)
val fileName = file.name
val filePart = MultipartBody.Part.createFormData("file", fileName, fileBody)
// 构建请求体
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addPart(filePart)
.build()
// 创建请求
val request = okhttp3.Request.Builder()
.url("url") // 替换为目标服务器的上传接口地址
.post(requestBody)
.build()
// 异步执行请求
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
// 处理网络错误或者异常
println("Upload failed with exception: ${e.message}")
}
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
println("File uploaded successfully!")
println(response.body()?.string())
} else {
// 处理非成功的HTTP状态码
println("Upload failed with HTTP error: ${response.code}")
}
}
})
}
七、补充
①client
这是OkHttpClient的实例,它是发送HTTP请求的基础。通常会先创建一个OkHttpClient对象,配置一些参数如超时时间、缓存策略等,然后用这个客户端实例来发送请求。
②enqueue
在OkHttp中,enqueue 方法用于异步地执行一个HTTP请求。当调用 enqueue 方法时,OkHttp会立即返回一个 Call 对象,但不会等待响应。相反,它会在响应准备好时,调用你在 enqueue 方法中提供的 Callback 对象的 onResponse 或 onFailure 方法。
这是 enqueue 方法的一个典型用法,它允许在不阻塞主线程的情况下执行网络请求。这对于Android应用来说非常重要,因为网络操作通常是耗时的,并且不应该在主线程(UI线程)上执行。
③newCall(request)
newCall方法接收一个Request对象作为参数。这个Request对象是在此之前构建的,包含了请求的所有细节,比如URL、HTTP方法(GET、POST等)、请求头、请求体等。调用newCall后,OkHttp会返回一个Call对象,这个对象代表了即将执行的HTTP调用。
④object : Callback {}
object : Callback {}: 这里创建了一个匿名内部类,实现了Callback接口。Callback是OkHttp提供的一个接口,它定义了两个方法:
- onFailure(Call, IOException): 当请求因为某种错误(如网络不可用、超时等)失败时调用。IOException参数提供了失败的原因。
- onResponse(Call, Response): 当请求成功并收到服务器响应时调用。Response对象包含了服务器返回的所有信息,如响应码、响应头和响应体。
⑤runOnUiThread
它用于从非UI线程(如后台线程或工作线程)切换到UI线程来执行操作,特别是与UI相关的操作,如更新UI组件的状态。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)