手把手带你玩转Android gRPC:一篇搞定原理、配置与客户端开发

0 阅读14分钟

导读

在这篇文章中,你能学会 RPC、 GRPC 是什么,protobuf 是什么,怎么定义,以及如何在 Android开发中使用。

gRPC 是什么

RPC是什么

RPC (Remote Procedure Call,远程过程调用) 是一种计算机通信协议。它的核心目标是:让调用远程服务器上的函数,看起来像调用本地函数一样简单。

如果没有 RPC,你需要自己处理网络连接、序列化数据(把对象转成字节流)、发送请求、等待响应等极其繁琐的步骤。而 RPC 框架 把这些底层的复杂逻辑封装好了,让开发者只需关注业务代码。

// ----使用传统的方式请求接口----
val params: Params = Params("admin", "1234")
// 1. 需要知道请求地址,发送的参数需要转成 json 字符串
val res = await request("/login", toJson(params))
// 2. 解析返回的数据,又要做一次反序列化
println(fromJson<Data>(res.body()))


// ----使用 RPC 的方式请求接口----
//  直接调用生成的方法,获取结果
val params: Params = Params("admin", "1234")
val data: Data = await LoginStub.Login(params)
println(data)

这里可以看出来几个优点:

  1. 不用记接口的地址,直接调用本地函数
  2. 不用每次请求都重复写序列化和反序列代码
  3. 不用定义 Params 和 Data(定义完 proto 后会自动生成)

简单来讲,简化了接口调用。

相比 REST 的优势

既然 REST (HTTP/JSON) 已经统一了互联网,为什么在大厂(字节、阿里、腾讯、Google)的内部,微服务之间却要“多此一举”去搞一套 RPC 框架。

其实,REST 是为了“通用性”设计的,而 RPC 是为了“极致性能”设计的。

在大规模分布式系统中,微服务之间的调用频率可能达到每秒 千万级。这时,REST 的一些特性就变成了“负担”。

性能:二进制 vs 文本

  • REST (JSON) :JSON 是文本格式。如果你传一个数字 12345,JSON 会把它当成 5 个字符(5 个字节)传输。传输前要序列化,接收后要解析字符串。
  • RPC (Protobuf) :使用二进制编码。同样的数字 12345 在二进制中可能只占 2 个字节。
    • 结果:RPC 的数据包更小,解析速度快 5-10 倍,极大地节省了 CPU 和带宽。

效率:HTTP/2 vs HTTP/1.1

虽然现在的 REST 也可以跑在 HTTP/2 上,但大多数传统的 REST API 仍停留在 HTTP/1.1。

  • REST (1.1) :每次请求通常都要经历“建立连接 -> 发送 -> 关闭”或者复杂的长连接管理。头部信息(Header)重复传输,浪费严重。
  • RPC (gRPC/HTTP2) :原生支持多路复用。在一个 TCP 连接上可以同时发出一千个调用,互不干扰。此外,HTTP/2 还会压缩 Header,效率提升巨大。

开发体验:强类型契约 vs 弱类型文档

这是前后端分离开发最感同身受的一点:

  • REST:后端写完接口,得写 Swagger 文档,前端/其他后端再照着文档手动写代码。如果后端改了字段名没通知,程序运行时就会直接报错(报错还不一定好找)。
  • RPC代码即文档
    • 你定义了一个 .proto 文件,执行编译。
    • 客户端和服务端自动生成对应的类和方法。
    • 如果你改了字段名,对方的代码在编译阶段就会报错。这种“编译期发现错误”的能力在大型工程中价值连城。

💡形象的比喻

  • REST 就像是邮寄信件: 格式是标准化的(信封、邮编),全世界谁都能寄,谁都能收,但你得手动拆信、读信。
  • RPC 就像是三体人: 两个大脑直接通过电信号同步信息,意念相同。速度极快,不需要语言转化,但前提是两个大脑必须接入同一套协议(三体人基因)。

传输成本:不仅仅是数据

在一个复杂的微服务链路中(比如 A 调 B,B 调 C,C 调 D),如果每一层都用 JSON 序列化和反序列化,累积的延迟(Latency)会非常恐怖。

gRPC是什么

gRPC (gRPC Remote Procedure Call) 是一个由 Google 开发的高性能、开源的通用 RPC 框架。

gRPC 的核心特点

  • 高效的数据传输:不同于 REST 使用文本格式(JSON/XML),gRPC 默认使用 Protocol Buffers (protobuf) 。这是一种二进制序列化格式,体积更小、速度更快。
  • 基于 HTTP/2:利用 HTTP/2 的多路复用、头部压缩和双向流特性,极大地提升了通信效率 。
  • 跨语言支持:你可以用 Python 写客户端,用 Go 或 Java 写服务端,它们之间可以无缝通信 。
  • 强类型契约:在编写代码前,需要先定义 .proto 文件,明确服务接口和消息结构,这减少了前后端对接时的沟通成本。

Protocol Buffers

Protocol Buffers(简称 Protobuf, 文件后缀 .proto)是 gRPC 的灵魂。它是 Google 开发的一种语言无关、平台无关、可扩展的序列化结构数据的方法

Protobuf 是二进制格式,直接映射到内存地址,解析速度比 JSON 快 5 到 10 倍。如果把 REST 比作发送“散装的快递”,那么 Protobuf 就是将物品“真空压缩”后的“标准集装箱”。

定义

一个简单的消息类型(message)定义,里面有 3 个字段:

syntax = "proto3"; // 使用 proto3 语法

message Player {
  int32 id = 1;      // 这里的 1, 2, 3 是“字段编号”,不是值
  string name = 2;   
  bool is_online = 3;
}

编译完成后大概会生成这样的代码:

// 1. 消息类(生成的实体)
class Player : GeneratedMessageLite<...> {
    val id: Int             // 对应 int32
    val name: String        // 对应 string
    val isOnline: Boolean   // 对应 bool (自动转为驼峰命名)
    
    // 还有一系列判断字段是否存在、序列化、反序列化的方法
}

// 2. 伴生对象或扩展方法提供的 DSL
inline fun player(block: PlayerKt.Dsl.() -> Unit): Player { ... }

注意:编译完成不会是简单的 data class,因为 gRPC 还要做一系列的序列化反序列化以及兼容性的处理。

字段类型

在前面的例子Player消息中,所有字段都是标量类型:1 个整数(id)、 1 个字符串(name)和 1 个布尔类型(is_online)。还可以定义枚举类型和使用其他消息作为类型。

字段编号

每个字段必须要有一个编号,编号满足下列要求:

  • 给定的编号必须在该消息的所有字段中是唯一的
  • 字段编号 1900019999 为 Protocol Buffers 实现保留。如果你在消息中使用这些保留的字段编号,protocol buffer 编译器会报错。
  • 不能使用任何先前保留( reserved 的字段编号,保留字段编号会在删除字段的时候用到。

重点:字段序号(Field Tags)的唯一性约束仅限于同一个 message 内部。不同的 message(无论是否在同一个 .proto 文件中)之间,序号是互不干扰的。

1 . 同一文件,不同 Message(序号可重复)

message User {
  int32 id = 1;    // 这里的 1 没问题
  string name = 2;
}

message Order {
  int32 id = 1;    // 这里的 1 也是合法的,完全独立
  double price = 2;
}
  1. 不同文件(序号可重复)

这是最常见的场景。比如你在 User.proto 里定义了序号 1,在 Product.proto 里也定义了序号1,它们在编译后生成的二进制数据中,会通过 Message 类型 来区分,而不仅仅靠号。

  1. 嵌套 Message(序号可重复)
message SearchResponse {
  message Result {
    string url = 1;    // 嵌套内的 1
    string title = 2;
  }
  
  repeated Result results = 1; // 外层的 1,与内部的 1 不冲突
  int32 total_pages = 2;
}

repeated 指的是可重复字段,即是个列表,repeated 字段永远不会是 null。如果没有传值,你拿到的是一个空的 ListemptyList()

⚠️ 真正不能重复的地方

message BadUser {
  int32 id = 1;
  string email = 1; // ❌ 错误!编译不会通过
}

💡 进阶小知识:序号范围的秘密

虽然你可以随便用序号,但 Protobuf 的序号其实是有“成本”的:

  • 1 到 15:占用 1 个字节(包含序号和字段类型)。
  • 16 到 2047:占用 2 个字节

建议:115 这些“黄金序号”留给那些最频繁出现、数据量最大的字段,这样可以进一步压榨性能,减小包体积。

删除字段

如果你要删除一个字段,首先从客户端代码中删除所有对该字段的引用,然后从消息中删除改字段定义。

但是,你必须保留已删除的字段的编号,因为你不保留该字段编号,其他开发人员可能会重复使用编号,导致程序出错。

因为你无法保证客户端和服务器同时更新,所以不能直接删除。

message Foo{
  string url = 1;
  string title = 2; // 如果现在要删除title 字段
}

// ❌ 直接删除 title 字段和它的字段编号
message Foo{
  string url = 1;
  int code = 2; // 可能后续会被其他开发用在其他字段上,导致数据错误
}

// ✅ 删除字段,但是保留编号,避免重复使用
message Foo{
  reserved 2; // 保留编号
  string url = 1;
}

保留字段

相当于禁用,后续不能使用,可以在一行里面写禁用多个字段编号:

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "title"; // 也可以禁用字段名称
}

这里9 to 11 相当于写 9, 10, 11

💡 核心:在 Protobuf 中,字段名只是给开发者看的“外号”,字段编号才是数据在网络中穿梭的“唯一身份证”。身份证一旦注销,终身不得重新启用。

如果不遵守规则会发生什么?

  • 数据错乱:如果旧版 App 还在发 email (编号2),你把编号 2 改成了 phone,新版服务器会把用户的 Email 当成电话号码存进数据库。
  • 解析失败:如果类型从 string 改成了 int32,Protobuf 可能会解析出一堆乱码,导致业务逻辑出现无法预知的 else 分支。

服务

在 gRPC 的世界里,如果说 message(消息)定义的是数据的“长相” ,那么 service (服务)定义的就是数据的“动向”。

简单来说,service 定义的是一套远程调用的接口契约(Interface Contract) 。它规定了服务端可以提供哪些功能,以及客户端可以如何调用这些功能。

1. 核心定义:它是“动作”的集合

.proto 文件中,service 块包含了若干个 rpc 方法。每一个 rpc 方法都像是一个跨越网络的函数声明

message OrderRequest {...}
message OrderResponse {...}

service OrderService {
  // 1. 定义方法名:CreateOrder
  // 2. 定义输入:OrderRequest (必须是 message 类型)
  // 3. 定义输出:OrderResponse (必须是 message 类型)
  rpc CreateOrder (OrderRequest) returns (OrderResponse);
}

2. Service 定义的四种通信模式

这是 service 最强大的地方。它不仅能定义“一问一答”,还能定义“流式通讯”:

模式定义方式描述场景举例
简单 RPC (Unary)rpc Method(Req) returns (Res);客户端发一个请求,服务端回一个响应。登录、查询余额
服务端流 (Server Streaming)rpc Method(Req) returns (stream Res);客户端发一个请求,服务端连续返回多个响应。股票行情、进度条更新
客户端流 (Client Streaming)rpc stream Method(Req) returns (Res);客户端连续发多个请求,服务端最后回一个响应。上传大文件、物联网轨迹上报
双向流 (Bi-directional)rpc stream Method(Req) returns (stream Res);双方同时发送和接收数据,互不等待。实时聊天、多人协作编辑

实践-在 Android 中使用 gRPC

1. 配置 build.gradle

apply plugin: 'com.google.protobuf'

dependencies {
    // gRPC核心组件
    api "io.grpc:grpc-okhttp:1.78.0" // gRPC的OkHttp传输实现,负责HTTP/2通信
    api "io.grpc:grpc-protobuf-lite:1.78.0" // Protocol Buffers轻量级序列化支持
    api "io.grpc:grpc-stub:1.78.0" // gRPC客户端和服务端存根生成器
    api "io.grpc:grpc-kotlin-stub:1.5.0" // Kotlin语言的gRPC存根支持
    // 序列化支持
    implementation "com.google.protobuf:protobuf-kotlin-lite:4.33.5" // Protocol Buffers的Kotlin轻量级支持
}

// Protobuf编译器配置
protobuf {
    // 基础设置
    protoc {
        // 指定编译器版本
        artifact = "com.google.protobuf:protoc:3.25.1"
    }
    // 定义了三个代码生成插件:
    plugins {
        // java插件:生成Java代码
        java {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.63.0'
        }
        // grpc插件:生成gRPC Java服务代码
        grpc {
            artifact = 'io.grpc:protoc-gen-grpc-java:1.63.0'
        }
        // grpckt插件:生成gRPC Kotlin代码
        grpckt {
            artifact = 'io.grpc:protoc-gen-grpc-kotlin:1.4.1:jdk8@jar'
        }
    }
    // 代码生成任务配置
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                // 生成轻量级Java代码
                java {
                    option 'lite'
                }
                // 生成轻量级Kotlin代码
                kotlin {
                    option 'lite'
                }
            }
            task.plugins {
                // 生成轻量级gRPC代码
                grpc {
                    option 'lite'
                }
                // 生成轻量级gRPC Kotlin代码
                grpckt {
                    option 'lite'
                }
            }
        }
    }
}

主要功能:

  • proto文件编译:将Protocol Buffers定义文件(.proto)编译成各种语言的代码
  • gRPC服务生成:自动生成gRPC客户端和服务端代码
  • 多语言支持:同时支持JavaKotlin代码生成
  • 轻量级优化:使用'lite'选项减少生成代码的体积
  • 自动化集成:与Gradle构建系统无缝集成

完成后要执行 Sync Gradle

2. 编写 proto 文件

这里在指定目录的文件夹下创建 proto文件,如果没有 proto 文件夹,先建一个文件夹,不然编译的时候会找不到 proto 文件。

MyAndroidApp/
├── app/
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/         # 你的 Kotlin/Java 代码
│   │   │   ├── proto/        # 👈 必须放在这里!请手动创建这个文件夹(如果不存在的话)。
│   │   │   │   ├── auth.proto
│   │   │   │   └── player.proto
│   │   │   └── AndroidManifest.xml
│   └── build.gradle.kts

如果想修改默认扫码的 proto 文件夹路径,可以去 build.gradle 里面设置,但是一般没必要改。

新建一个 auth.proto 文件,内容如下:

syntax = "proto3";

package auth;

option java_package = "com.demo.android_client.proto";
option java_multiple_files = true;

// 1. 定义登录请求
message LoginRequest {
  string username = 1;
  string password = 2;
}

// 2. 定义登录响应
message LoginResponse {
  bool success = 1;
  string token = 2;      // 登录成功后的令牌
  string message = 3;    // 错误信息或提示
}

// 3. 定义服务
service AuthService {
  // 简单 RPC:发送一个请求,得到一个响应
rpc Login (LoginRequest) returns (LoginResponse);
}

这里有一些新字段,补充一下 proto 配置的说明:

  • package:包名,用于解决不同 proto 文件里面有相同 messageservice 出现冲突的问题,必须写。
  • option java_package:用于配置包名,默认用包名,最好设置一下,符合 Android 的规范。
  • option java_multiple_files:默认值 false,表示要不要将 message 编译成独立的文件,建议设为 true,避免定义的消息多了,打包一个很大的类出来。

编写完成后要点击编译按钮进行编译,编译完成后就会看到以下文件:

3. 修改 proto 文件

如果修改了 proto 文件,一定要重新编译,最好先 clean build 一下,不然会有缓存。

4. 编写测试代码

新建一个 GrpcViewModel.kt 文件,用于连接 gRPC,和实现登录方法:

package com.demo.android_client

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.demo.android_client.proto.AuthServiceGrpcKt
import com.demo.android_client.proto.loginRequest
import io.grpc.ManagedChannel
import io.grpc.ManagedChannelBuilder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class GrpcViewModel : ViewModel() {
    // 如果是 Android 模拟器访问电脑本机,通常用 10.0.2.2,端口号在服务端配置
    val channel: ManagedChannel = ManagedChannelBuilder.forAddress("10.0.2.2", 50051)
        .usePlaintext() // 开发环境通常关闭 TLS 加密
        .build()

    // 创建支持协程的 Stub
    val stub = AuthServiceGrpcKt.AuthServiceCoroutineStub(channel)

    private val _toastMessage = MutableStateFlow<String?>(null)
    val toastMessage = _toastMessage.asStateFlow()

    fun doLogin() {
        // 在协程作用域中启动
        viewModelScope.launch {
            try {
                // 构造请求对象
                val request = loginRequest {
                    username = "admin"
                    password = "123"
                }

                // 发起异步请求
                val response = stub.login(request)

                // 处理结果
                if (response.success) {
                    _toastMessage.value = "登录成功,Token: ${response.token}"
                    println("登录成功,Token: ${response.token}")
                } else {
                    _toastMessage.value = "登录失败: ${response.message}"
                    println("登录失败: ${response.message}")
                }
            } catch (e: Exception) {
                _toastMessage.value = "网络错误: ${e.message}"
                println("网络错误: ${e.message}")
            }
        }
    }
}

这里需要注意的是:message 编译完成生成 kotlin 代码(loginRequest),要用 DSL 语法进行初始化,而不是传统的 new 一个类的写法。当然,你也可以用 build 的方式进行赋值:

// 传统写法
val request = LoginRequest.newBuilder()
    .setUsername("admin")
    .setPassword("123")
    .build()

// 优雅写法
val request = loginRequest {
    username = "admin"
    password = "123"
}

再创建一个 Activity 用于触发登录逻辑:

package com.demo.android_client

import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
    private lateinit var viewModel: GrpcViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        
        // 初始化 ViewModel
        viewModel = ViewModelProvider(this)[GrpcViewModel::class.java]
        
        // 设置按钮点击事件
        findViewById<Button>(R.id.button_login).setOnClickListener {
            viewModel.doLogin()
            Toast.makeText(this, "正在尝试登录...", Toast.LENGTH_SHORT).show()
        }

        // 观察 Toast 消息
        lifecycleScope.launch {
            viewModel.toastMessage.collect { message ->
                message?.let {
                    Toast.makeText(this@MainActivity, it, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/button_login"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="登录"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/main_text" />

    <TextView
        android:id="@+id/main_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="gRPC Login Demo"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toTopOf="@+id/button_login"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

5. 验证

在模拟器运行,点击登录:

看到 toast,证明成功获取到服务端的返回🎉。这就是一个 gRPC 在 Android 端应用最简单的 demo。

详细代码见文末链接。

完整 demo

内容包含一个 Android 写的客户端和 go 写的服务端,使用 gRPC 进行通讯。

地址:gitee.com/wcly/grpc

参考