人在外面,手机打开电脑里的照片?Android Java + Go + webrpc 跨端预览
独立开发者笔记,非 webrpc 官方文章。Token 请用自己的占位替换。
人不在电脑旁边时,有时只想用手机看一眼家里机器上的某张图。发微信会压缩,传网盘又绕,自己搭 frp 还得弄公网机器。后来我用 webrpc 试了一条更直接的路:电脑用 Go 守着图片,手机用 Java App 点一下「拉取」,经 P2P 会话把文件拉过来显示。
webrpc 本身是面向无公网 IP 的跨平台 P2P 通信 SDK——Token 标识设备,登录后建加密会话,再用 SendData / SendFile 收发。官网:https://www.webrpc.cn/
注意方向:是手机预览电脑上的图,不是手机往电脑传图。
电脑侧示例文件:

手机 App 界面(预览成功后):

为什么不用网盘 / 邮箱 / frp?
| 做法 | 痛点 |
|---|---|
| 发微信/邮箱 | 压缩、有痕、不适合私有文件 |
| 传到网盘再下 | 绕一圈云,隐私与延迟都差 |
| frp 开 HTTP 静态目录 | 要公网 VPS,还要管暴露面 |
| webrpc | 手机和电脑都可在 NAT 后,优先 P2P,API 接近 RPC |
一句话:要的是「跨网预览自家电脑文件」,不是「先拥有公网入口」。
架构(电脑推文件,手机预览)
┌─────────────────┐ webrpc P2P ┌──────────────────┐
│ 电脑 Go Agent │ ◄── OpenSession / 拉取 ──► │ 安卓 Java App │
│ Token = PC │ ── SendFile(image.png) ──► │ Token = PHONE │
│ 持有 image.png │ │ ImageView 显示 │
└─────────────────┘ └──────────────────┘
交互约定(故意写短,便于抄):
- 手机登录,开回调线程,等待文件帧
- 手机
OpenSession(PC),发送文本:PULL|image.png - 电脑回调收到请求,对本
sessionId执行SendFile("./image.png") - 手机回调
type=1文件流:落盘 →BitmapFactory→ImageView
准备
- https://www.webrpc.cn/ 注册,准备两个 Token:
YOUR_TOKEN_PC/YOUR_TOKEN_PHONE - 下载 macOS/Linux SDK(电脑) 与 Android SDK(手机)
- 电脑放好
image.png(可用本文同目录样例) - Android Studio,JNA 或官网 Android 绑定方式加载
libwebrpc(下文按与桌面 Java 相同的 JNA 心智写核心;真机请换成 Android.so与jniLibs)
目录示意:
pc-photo-preview/
├── image.png
├── pc/main.go # 电脑发送端
└── android/ # 仅核心类,非整包工程
├── WebrpcBridge.java
└── PreviewActivity.java
一、电脑端 Go:收到 PULL 就 SendFile
package main
/*
#cgo CFLAGS: -I.
#cgo darwin LDFLAGS: -L. -lwebrpc-Mac
#cgo linux,amd64 LDFLAGS: -L. -lwebrpc-Linux
#cgo linux,arm64 LDFLAGS: -L. -lwebrpc-Linux-arm64
#if defined(__APPLE__)
#include "libwebrpc-Mac.h"
#elif defined(__linux__) && defined(__aarch64__)
#include "libwebrpc-Linux-arm64.h"
#elif defined(__linux__)
#include "libwebrpc-Linux.h"
#endif
#include <stdlib.h>
*/
import "C"
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"strings"
"time"
"unsafe"
)
func main() {
token := C.CString("YOUR_TOKEN_PC")
pass := C.CString("YOUR_PASSWORD_PC")
perm := C.CString("")
defer C.free(unsafe.Pointer(token))
defer C.free(unsafe.Pointer(pass))
defer C.free(unsafe.Pointer(perm))
cli := C.WebrpcClient_New(token, pass, perm)
if cli == 0 {
log.Fatal("New failed")
}
for C.WebrpcClient_LoginStatus(cli) == 0 {
time.Sleep(2 * time.Second)
}
port := int(C.WebrpcClient_GetReceivePort(cli))
log.Printf("[PC] 登录成功,回调端口=%d,等待手机 PULL…", port)
go serveCallback(cli, port)
select {}
}
func serveCallback(cli C.GoUintptr, port int) {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
for {
sidBuf := make([]byte, 4)
if _, err := io.ReadFull(conn, sidBuf); err != nil {
return
}
sid := binary.BigEndian.Uint32(sidBuf)
typ := make([]byte, 1)
if _, err := io.ReadFull(conn, typ); err != nil {
return
}
if typ[0] != 2 { // 只处理数据请求;文件帧按需跳过
continue
}
lenBuf := make([]byte, 4)
io.ReadFull(conn, lenBuf)
n := binary.BigEndian.Uint32(lenBuf)
payload := make([]byte, n)
io.ReadFull(conn, payload)
req := string(payload)
log.Printf("[PC] 收到请求 session=%d: %s", sid, req)
if strings.HasPrefix(req, "PULL|") {
name := strings.TrimPrefix(req, "PULL|")
if name == "" {
name = "image.png"
}
go sendFile(cli, sid, name)
}
}
}
func sendFile(cli C.GoUintptr, sid uint32, path string) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
start := time.Now()
ret := C.WebrpcClient_SendFile(cli, C.uint(sid), cpath)
log.Printf("[PC] SendFile %s ret=%d elapsed=%v", path, int(ret), time.Since(start))
}
跑起来大致是这样:

# 把 image.png 与动态库放在同级后
cd pc && go run .
二、安卓端 Java:核心只要两块
下面是可嵌入的核心类,不是完整 Android Studio 工程。你需要自行:
- 把 Android 版
libwebrpc-*.so放进jniLibs - 用 JNA 或 JNI 暴露与官网一致的 C API(下列接口名与 开发文档 · Java 对齐)
AndroidManifest按需声明网络权限
1)回调里收文件帧并回调 UI
文件帧格式(官网):
sessionId:u32 BE | type:u8=1 | nameLen:u32 | name | dataLen:u32 | fileBytes
// PreviewCallback.java —— 读本机回调 TCP,遇到文件就交给 Activity
final class PreviewCallback implements Runnable {
interface Listener {
void onImageBytes(String fileName, byte[] data);
void onText(String text);
}
private final long handle;
private final int port;
private final Listener listener;
private final WebrpcLib lib; // JNA 接口,同官网 Java 示例
PreviewCallback(WebrpcLib lib, long handle, int port, Listener listener) {
this.lib = lib;
this.handle = handle;
this.port = port;
this.listener = listener;
}
@Override public void run() {
try (Socket socket = new Socket("127.0.0.1", port);
DataInputStream in = new DataInputStream(socket.getInputStream())) {
while (true) {
int sessionId = in.readInt();
int type = in.readUnsignedByte();
if (type == 2) {
int n = in.readInt();
byte[] data = in.readNBytes(n);
listener.onText(new String(data, StandardCharsets.UTF_8));
} else if (type == 1) {
int nameLen = in.readInt();
String name = new String(in.readNBytes(nameLen), StandardCharsets.UTF_8);
int dataLen = in.readInt();
byte[] file = in.readNBytes(dataLen);
listener.onImageBytes(name, file);
}
}
} catch (IOException e) {
Log.e("webrpc", "callback ended", e);
}
}
}
2)Activity:登录 → 拉取 → ImageView
// PreviewActivity.java(关键路径)
public class PreviewActivity extends AppCompatActivity {
private WebrpcLib lib;
private long handle;
private ImageView imageView;
private TextView statusView;
@Override protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_preview);
imageView = findViewById(R.id.imagePreview);
statusView = findViewById(R.id.status);
findViewById(R.id.btnPull).setOnClickListener(v -> pullFromPc());
new Thread(this::bootWebrpc).start();
}
private void bootWebrpc() {
lib = Native.load("webrpc", WebrpcLib.class); // 按你的 .so 名调整
handle = lib.WebrpcClient_New("YOUR_TOKEN_PHONE", "YOUR_PASSWORD_PHONE", "");
while (lib.WebrpcClient_LoginStatus(handle) == 0) {
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
}
int port = lib.WebrpcClient_GetReceivePort(handle);
runOnUiThread(() -> statusView.setText("已登录,回调端口 " + port));
new Thread(new PreviewCallback(lib, handle, port, new PreviewCallback.Listener() {
@Override public void onImageBytes(String fileName, byte[] data) {
Bitmap bmp = BitmapFactory.decodeByteArray(data, 0, data.length);
runOnUiThread(() -> {
imageView.setImageBitmap(bmp);
statusView.setText("已预览:" + fileName + " (" + data.length + " bytes)");
});
}
@Override public void onText(String text) {
runOnUiThread(() -> statusView.setText(text));
}
}), "webrpc-cb").start();
}
private void pullFromPc() {
new Thread(() -> {
int sid = lib.WebrpcClient_OpenSession(handle, "YOUR_TOKEN_PC", "");
if (sid == 0) {
runOnUiThread(() -> statusView.setText("OpenSession 失败:电脑端是否在线?"));
return;
}
String req = "PULL|image.png";
int ret = lib.WebrpcClient_SendData(handle, sid, req, req.length(), 5000);
runOnUiThread(() -> statusView.setText("已请求拉取,SendData ret=" + ret));
}).start();
}
@Override protected void onDestroy() {
if (handle != 0) lib.WebrpcClient_Free(handle);
super.onDestroy();
}
}
布局里一个 ImageView + 按钮「从电脑拉取 image.png」即可。跑通后手机界面就是文首那张 App 截图:标题「电脑图片预览」、中间显示 image.png、底部拉取按钮。
三、怎么联调
- 先开电脑
go run .,确认登录成功 - 再开 App,确认手机登录成功
- 手机点「从电脑拉取 image.png」
- 电脑日志出现
PULL|image.png与SendFile ok - 手机
ImageView显示与image.png相同的画面
两端可以一个在家宽、一个在 4G,这才是无公网 IP 场景的意义。
四、什么场景适合这么做?
比较适合:私有相册预览、现场看工控截图、家庭电脑/NAS 上的单张图片快速查看。
不太适合:浏览器里的实时视频会议(更该看 WebRTC);只是临时把一个网页目录暴露出去(frp 往往更快)。
没有公网 IP 时,链路可以收成:电脑 Go 登录并等待 PULL|文件名,收到后 SendFile;手机 Java 登录、OpenSession 到电脑 Token、发拉取请求,在回调文件帧里解码显示。双方都不必做端口映射。
五、几个坑(安卓侧多留意)
- 必须用 Android 对应 ABI 的 so(armeabi-v7a / arm64-v8a),不要拿桌面 dylib 硬塞
- 回调在子线程,更新
ImageView必须runOnUiThread - 大图内存:可先写缓存文件再
BitmapFactory.Options.inSampleSize抽样 - OpenSession 返回 0:电脑未登录、Token 填反、或网络握手失败——先同 Wi‑Fi 验证
- 回包/发文件不要堵死回调读循环——
SendFile放在电脑侧收到请求后的新 goroutine(上文已如此)
小结
这篇演示的是一条很常见却常被「网盘/frp」绕远的需求:手机预览电脑本地图片。
webrpc 提供的是跨 NAT 的会话与 SendFile;Go 负责守文件,Java 负责请求与 ImageView。完整 SDK、Token 与多语言文档见官网:https://www.webrpc.cn/。
仅用于合法业务。握手无法保证任意网络 100% 成功,产品层请做超时与重试。
更多推荐




所有评论(0)