Demo的需求,本质上是:

Android 端选择/生成一张图片 → 上传到服务器 → 生成一个公网 URL → 把这个 URL 分享给别人 →
对方在手机浏览器中打开并预览图片

这个方案比单纯 Intent 分享本地图片 更适合“跨手机浏览器预览”。

一、先说明一个关键点

仅靠 Android 本地代码,不能直接让“对方浏览器打开预览”
因为浏览器预览需要一个可访问的网络地址,所以必须有:

一个服务器

或图片 / 对象存储

或你自己的上传接口

也就是说,这个 Demo 至少包含两部分:

1)Android 客户端
负责:

选择图片

上传图片

获取 URL

分享 URL

2)服务端
负责:

接收上传的图片

保存图片

返回可访问链接

二、我给你的 Demo 方案

为了让你更容易跑通,我给你一个最简单可运行结构:

技术方案

Android 端
Kotlin

Retrofit 上传图片

FileProvider / 相册选择

分享链接

服务端
我这里先给你一个最简单 Node.js Demo 方案
因为它容易搭建、代码少、适合测试

三、完整流程

Android App 选择一张图片

点击上传

上传到服务器

服务器保存图片

返回图片访问地址,例如:

http://你的IP:3000/uploads/abc123.jpg

Android 把这个地址分享出去

对方在浏览器中打开这个链接

浏览器直接显示图片

四、注意:局域网测试和公网测试

1)局域网测试
如果两台手机和电脑在同一 Wi-Fi 下,可以用你电脑的局域网 IP:
例如:

http://192.168.1.100:3000/uploads/test.jpg

这样同一局域网内的其他手机浏览器可以访问。

2)公网测试
如果你希望任意手机都能访问,需要:

部署到云服务器

或用内网穿透工具

或对象存储/CDN

五、第二版 Demo:Android 客户端完整代码

1. 功能说明

这个 Android Demo 实现:

  • 从相册选择图片

  • 预览图片

  • 上传图片到服务器

  • 返回图片 URL

  • 分享图片 URL 给其他手机

  • 其他手机浏览器打开即可预览

2. app/build.gradle

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    namespace 'com.example.imageuploaddemo'
    compileSdk 35

    defaultConfig {
        applicationId "com.example.imageuploaddemo"
        minSdk 24
        targetSdk 35
        versionCode 1
        versionName "1.0"
    }

    buildFeatures {
        viewBinding true
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = '17'
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.15.0'
    implementation 'androidx.appcompat:appcompat:1.7.0'
    implementation 'com.google.android.material:material:1.12.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.2.0'

    implementation 'com.squareup.retrofit2:retrofit:2.11.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}

3. AndroidManifest.xml

如果你上传网络图片,需要网络权限:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:label="ImageUploadDemo"
        android:supportsRtl="true"
        android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

    </application>
</manifest>

4. activity_main.xml

<?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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="250dp"
        android:layout_height="250dp"
        android:background="#EEEEEE"
        android:scaleType="centerCrop"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <Button
        android:id="@+id/btnPick"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="选择图片"
        app:layout_constraintTop_toBottomOf="@id/imageView"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="16dp" />

    <Button
        android:id="@+id/btnUpload"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="上传图片"
        app:layout_constraintTop_toBottomOf="@id/btnPick"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="12dp" />

    <TextView
        android:id="@+id/tvUrl"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="上传后的链接会显示在这里"
        android:textSize="14sp"
        android:padding="8dp"
        android:background="#F5F5F5"
        app:layout_constraintTop_toBottomOf="@id/btnUpload"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="16dp" />

    <Button
        android:id="@+id/btnShareUrl"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="分享链接"
        app:layout_constraintTop_toBottomOf="@id/tvUrl"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_marginTop="12dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

5. UploadResponse.kt

package com.example.imageuploaddemo

data class UploadResponse(
    val success: Boolean,
    val url: String,
    val message: String? = null
)

6. ApiService.kt

package com.example.imageuploaddemo

import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part

interface ApiService {
    @Multipart
    @POST("/upload")
    fun uploadImage(
        @Part file: MultipartBody.Part
    ): Call<UploadResponse>
}

7. MainActivity.kt

package com.example.imageuploaddemo

import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.example.imageuploaddemo.databinding.ActivityMainBinding
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.File
import java.io.FileOutputStream

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var selectedImageUri: Uri? = null
    private var uploadedImageUrl: String? = null

    // 这里替换成你的服务端地址
    // 局域网测试示例: http://192.168.1.100:3000/
    private val baseUrl = "http://192.168.1.100:3000/"

    private val apiService: ApiService by lazy {
        val client = OkHttpClient.Builder().build()

        Retrofit.Builder()
            .baseUrl(baseUrl)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java)
    }

    private val pickImageLauncher =
        registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
            if (uri != null) {
                selectedImageUri = uri
                binding.imageView.setImageURI(uri)
                binding.tvUrl.text = "已选择图片,点击上传"
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.btnPick.setOnClickListener {
            pickImageLauncher.launch("image/*")
        }

        binding.btnUpload.setOnClickListener {
            if (selectedImageUri == null) {
                Toast.makeText(this, "请先选择图片", Toast.LENGTH_SHORT).show()
            } else {
                uploadImage(selectedImageUri!!)
            }
        }

        binding.btnShareUrl.setOnClickListener {
            if (uploadedImageUrl.isNullOrEmpty()) {
                Toast.makeText(this, "请先上传图片并获取链接", Toast.LENGTH_SHORT).show()
            } else {
                shareUrl(uploadedImageUrl!!)
            }
        }
    }

    private fun uploadImage(uri: Uri) {
        try {
            val file = uriToFile(uri)
            val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
            val body = MultipartBody.Part.createFormData("file", file.name, requestFile)

            binding.tvUrl.text = "上传中..."

            apiService.uploadImage(body).enqueue(object : Callback<UploadResponse> {
                override fun onResponse(
                    call: Call<UploadResponse>,
                    response: Response<UploadResponse>
                ) {
                    if (response.isSuccessful && response.body() != null) {
                        val result = response.body()!!
                        if (result.success) {
                            uploadedImageUrl = result.url
                            binding.tvUrl.text = result.url
                            Toast.makeText(this@MainActivity, "上传成功", Toast.LENGTH_SHORT).show()
                        } else {
                            binding.tvUrl.text = "上传失败:${result.message ?: "未知错误"}"
                        }
                    } else {
                        binding.tvUrl.text = "上传失败:服务器返回异常"
                    }
                }

                override fun onFailure(call: Call<UploadResponse>, t: Throwable) {
                    binding.tvUrl.text = "上传失败:${t.message}"
                }
            })
        } catch (e: Exception) {
            e.printStackTrace()
            binding.tvUrl.text = "上传异常:${e.message}"
        }
    }

    private fun shareUrl(url: String) {
        val intent = Intent(Intent.ACTION_SEND).apply {
            type = "text/plain"
            putExtra(Intent.EXTRA_TEXT, url)
        }
        startActivity(Intent.createChooser(intent, "分享链接"))
    }

    private fun uriToFile(uri: Uri): File {
        val fileName = getFileName(uri) ?: "upload_image.jpg"
        val tempFile = File(cacheDir, fileName)

        contentResolver.openInputStream(uri)?.use { inputStream ->
            FileOutputStream(tempFile).use { outputStream ->
                inputStream.copyTo(outputStream)
            }
        }

        return tempFile
    }

    private fun getFileName(uri: Uri): String? {
        var name: String? = null
        val cursor: Cursor? = contentResolver.query(uri, null, null, null, null)
        cursor?.use {
            val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
            if (it.moveToFirst() && nameIndex >= 0) {
                name = it.getString(nameIndex)
            }
        }
        return name
    }
}

六、服务端 Demo(Node.js 版)

下面这个服务端很简单,用来本地测试很方便。

1. 新建文件夹

比如:

E:\image-upload-server

2. 初始化项目

在该目录打开命令行,执行:

npm init -y
npm install express multer cors

3. 新建 server.js

const express = require("express");
const multer = require("multer");
const cors = require("cors");
const path = require("path");
const fs = require("fs");

const app = express();
const PORT = 3000;

app.use(cors());
app.use("/uploads", express.static(path.join(__dirname, "uploads")));

if (!fs.existsSync("uploads")) {
  fs.mkdirSync("uploads");
}

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/");
  },
  filename: function (req, file, cb) {
    const ext = path.extname(file.originalname);
    const uniqueName = Date.now() + "_" + Math.floor(Math.random() * 10000) + ext;
    cb(null, uniqueName);
  },
});

const upload = multer({ storage });

app.post("/upload", upload.single("file"), (req, res) => {
  if (!req.file) {
    return res.json({
      success: false,
      url: "",
      message: "未接收到文件",
    });
  }

  const serverIp = getLocalIp();
  const fileUrl = `http://${serverIp}:${PORT}/uploads/${req.file.filename}`;

  res.json({
    success: true,
    url: fileUrl,
    message: "上传成功",
  });
});

function getLocalIp() {
  const os = require("os");
  const interfaces = os.networkInterfaces();
  for (let name in interfaces) {
    for (let iface of interfaces[name]) {
      if (iface.family === "IPv4" && !iface.internal) {
        return iface.address;
      }
    }
  }
  return "127.0.0.1";
}

app.listen(PORT, "0.0.0.0", () => {
  console.log(`Server running at http://0.0.0.0:${PORT}`);
});

4. 启动服务端

执行:

node server.js

启动成功后,服务会监听:

http://你的电脑局域网IP:3000

比如:

http://192.168.1.100:3000

七、Android 端要改哪里

把 MainActivity.kt 里的:

private val baseUrl = "http://192.168.1.100:3000/"

改成你自己电脑真实的局域网 IP。

八、如果 Android 9+ 无法访问 HTTP

Android 9 以后默认限制明文 HTTP。
如果你的服务端是 http://,你需要额外配置。

1. AndroidManifest.xml 的 application 加:

android:usesCleartextTraffic="true"

完整示例:

<application
    android:allowBackup="true"
    android:label="ImageUploadDemo"
    android:supportsRtl="true"
    android:usesCleartextTraffic="true"
    android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">

九、运行方式

步骤
电脑启动 Node.js 服务端

手机和电脑连接同一 Wi-Fi

Android App 里把 baseUrl 改成电脑 IP

运行 App

选择图片

上传图片

得到 URL

分享 URL 给其他手机

其他手机浏览器打开链接即可预览

十、效果示例

假设上传成功返回:

http://192.168.1.100:3000/uploads/1716871234567_1234.jpg

别人收到这个链接后,在浏览器打开,就会直接看到图片。

十一、这个 Demo 的局限

这个版本适合:

  • 本地测试

  • 局域网预览

  • 原型验证

不适合直接上线,因为缺少:

  • 图片大小限制

  • 文件类型校验

  • 安全认证

  • HTTPS

  • CDN

  • 数据库存储

  • 防盗链

十二、如果你想做正式版,建议方案

正式环境一般会用:

Spring Boot + OSS/MinIO

Node.js + 对象存储

七牛云 / 阿里云 OSS / 腾讯云 COS

Firebase Storage

这样返回的是公网地址,其他手机不在同一个 Wi-Fi 下也能打开。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐