Android文件下载在各个Android版本上兼容

前言

Android 10(API 29)及以上启用了 Scoped Storage(分区存储),限制了对外部存储的直接访问

Android < 6.0(API 23):只需在 AndroidManifest.xml 声明:

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

Android 6.0 ~ 9(API 23~28):除了声明权限,还需动态申请

1
2
3
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQUEST_CODE);
}

Android 10+(API 29+)

默认启用 Scoped Storage,即使有权限也不能随意写入外部存储根目录。

可以使用以下三种方式

1
2
3
4
5
6
7
8
9
10
11
12
// App 私有目录(无需权限,卸载时自动删除)
File privateFile = new File(getFilesDir(), "file.txt");

// App 外部私有目录(无需权限,但可能在外部存储上)
File externalFile = new File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "file.pdf");

// 公共下载目录(需权限 + 适配 Scoped Storage)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 使用 MediaStore
} else {
File publicDownload = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
}

或者

使用Scoped Storage

下载工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
import android.Manifest;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;

/**
* 文件下载工具类,支持 Android 10+ MediaStore 和传统存储方式
*/
public class ZFileDownloader {

private static final String TAG = "ZFileDownloader";
private static final int REQUEST_WRITE_PERMISSION = 1001;
private static final int BUFFER_SIZE = 8192;
private static final int PROGRESS_UPDATE_INTERVAL = 100; // 进度更新间隔(ms)

// 复用 OkHttpClient 实例
private static volatile OkHttpClient sClient;

@NonNull
private static OkHttpClient getClient() {
if (sClient == null) {
synchronized (ZFileDownloader.class) {
if (sClient == null) {
sClient = new OkHttpClient.Builder()
.build();
}
}
}
return sClient;
}

public interface DownloadCallback {
/**
* 下载成功(通过 File)
* 注意:Android 10+ 可能 file == null,建议优先使用 {@link #onSuccess(Uri)}
*/
void onSuccess(@Nullable File file);

/**
* 下载成功(通过 Uri)
*/
void onSuccess(@NonNull Uri uri);

/**
* 下载失败
*/
void onFailure(@NonNull Exception e);

/**
* 下载进度回调
*
* @param bytesRead 已读取字节数
* @param totalBytes 总字节数(如果服务器未返回 content-length,可能为 -1)
*/
void onProgress(long bytesRead, long totalBytes);
}

/**
* 下载文件到 Downloads 目录
*
* @param activity Activity 上下文(用于权限检查和 UI 线程回调)
* @param url 下载地址
* @param fileName 保存的文件名
* @param mimeType 文件 MIME 类型(可为 null,默认 application/octet-stream)
* @param callback 下载回调
*/
public static void downloadFile(@NonNull Activity activity,
@NonNull String url,
@NonNull String fileName,
@Nullable String mimeType,
@NonNull DownloadCallback callback) {
OkHttpClient client = getClient();
Request request = new Request.Builder()
.url(url)
.build();

client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
activity.runOnUiThread(() -> callback.onFailure(e));
}

@Override
public void onResponse(@NonNull Call call, @NonNull Response response) {
if (!response.isSuccessful()) {
Exception error = new IOException("Response not successful: " + response.code());
activity.runOnUiThread(() -> callback.onFailure(error));
return;
}

ResponseBody body = response.body();
if (body == null) {
Exception error = new IOException("Response body is null");
activity.runOnUiThread(() -> callback.onFailure(error));
return;
}

long totalBytes = body.contentLength();

try (InputStream inputStream = body.byteStream()) {
DownloadResult result = saveFile(activity, inputStream, fileName, mimeType, totalBytes, callback);
activity.runOnUiThread(() -> {
callback.onSuccess(result.uri);
if (result.file != null) {
callback.onSuccess(result.file);
}
});
} catch (Exception e) {
activity.runOnUiThread(() -> callback.onFailure(e));
}
}
});
}

/**
* 保存文件结果
*/
private static class DownloadResult {
final Uri uri;
final File file;

DownloadResult(@NonNull Uri uri, @Nullable File file) {
this.uri = uri;
this.file = file;
}
}

/**
* 保存文件到本地存储
*/
private static DownloadResult saveFile(@NonNull Activity activity,
@NonNull InputStream inputStream,
@NonNull String fileName,
@Nullable String mimeType,
long totalBytes,
@NonNull DownloadCallback callback) throws IOException {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return saveViaMediaStore(activity, inputStream, fileName, mimeType, totalBytes, callback);
} else {
return saveViaLegacyStorage(activity, inputStream, fileName, totalBytes, callback);
}
}

/**
* Android 10+: 使用 MediaStore 保存文件
*/
private static DownloadResult saveViaMediaStore(@NonNull Activity activity,
@NonNull InputStream inputStream,
@NonNull String fileName,
@Nullable String mimeType,
long totalBytes,
@NonNull DownloadCallback callback) throws IOException {
ContentValues values = new ContentValues();
values.put(MediaStore.Downloads.DISPLAY_NAME, fileName);
values.put(MediaStore.Downloads.MIME_TYPE, mimeType != null ? mimeType : "application/octet-stream");
values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);

ContentResolver resolver = activity.getContentResolver();
Uri uri = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
}
if (uri == null) {
throw new IOException("Failed to create MediaStore entry");
}

try (OutputStream out = resolver.openOutputStream(uri)) {
if (out == null) {
throw new IOException("Cannot open output stream for Uri: " + uri);
}
copyStreamWithProgress(inputStream, out, totalBytes, activity, callback);
} catch (Exception e) {
// 写入失败时删除已创建的条目
resolver.delete(uri, null, null);
throw e;
}

return new DownloadResult(uri, null);
}

/**
* Android 10 以下: 使用传统外部存储保存文件
*/
private static DownloadResult saveViaLegacyStorage(@NonNull Activity activity,
@NonNull InputStream inputStream,
@NonNull String fileName,
long totalBytes,
@NonNull DownloadCallback callback) throws IOException {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
REQUEST_WRITE_PERMISSION);
throw new SecurityException("WRITE_EXTERNAL_STORAGE permission denied");
}

File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (dir != null && !dir.exists()) {
dir.mkdirs();
}

File file = new File(dir, fileName);
try (FileOutputStream out = new FileOutputStream(file)) {
copyStreamWithProgress(inputStream, out, totalBytes, activity, callback);
}

return new DownloadResult(Uri.fromFile(file), file);
}

/**
* 复制流并报告进度
*/
private static void copyStreamWithProgress(@NonNull InputStream in,
@NonNull OutputStream out,
long totalBytes,
@NonNull Activity activity,
@NonNull DownloadCallback callback) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
long downloaded = 0;
int bytesRead;
long lastUpdateTime = 0;

while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
downloaded += bytesRead;

// 限制进度更新频率,避免过于频繁的 UI 线程调用
long currentTime = System.currentTimeMillis();
if (currentTime - lastUpdateTime >= PROGRESS_UPDATE_INTERVAL) {
final long progress = downloaded;
activity.runOnUiThread(() -> callback.onProgress(progress, totalBytes));
lastUpdateTime = currentTime;
}
}
out.flush();

// 确保最后一次进度更新
final long finalProgress = downloaded;
activity.runOnUiThread(() -> callback.onProgress(finalProgress, totalBytes));
}
}

下载目录

兼容各个版本的文件下载目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static String getBaseDir(Context context) {
String baseDir;

// Android 10 (API 29) 及以上:使用应用专属外部存储(无需权限,自动沙盒)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
File externalFilesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (externalFilesDir != null) {
baseDir = externalFilesDir.getAbsolutePath();
} else {
// fallback to internal storage if external is unavailable
baseDir = context.getFilesDir().getAbsolutePath();
}
} else {
// Android 9 及以下:优先使用公共外部存储,否则用内部存储
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
baseDir = Environment.getExternalStorageDirectory().getAbsolutePath();
} else {
baseDir = context.getFilesDir().getAbsolutePath();
}
}
File file = new File(baseDir);
if (!file.exists()) {//判断文件目录是否存在
file.mkdirs();
}
return baseDir;
}