前言
最近在做物流App的工单模块,产品经理提了个需求:用户上传的维修照片要能点击查看大图,支持左右滑动切换,还要能下载保存。听起来简单,但真动手做的时候发现坑不少------ViewPager的缓存机制、手势冲突、权限适配、甚至删除后的数据同步都是问题。
这篇文章记录了我完整的实现过程,包括踩过的坑和最终选定的方案。代码已经在线上跑了半年多,基本稳定,分享出来给有同样需求的兄弟参考。
一、需求拆解
刚开始接到需求时,我先把功能点拆了一下:
表格
复制
| 功能点 | 优先级 | 难点 |
|---|---|---|
| 多图左右滑动浏览 | P0 | ViewPager的缓存和复用 |
| 单击隐藏/显示工具栏 | P0 | 与图片缩放手势的冲突 |
| 图片下载到本地 | P1 | Android 10+分区存储适配 |
| 长按菜单(保存/分享/复制链接) | P1 | 弹窗时机和坐标计算 |
| 删除图片并同步 | P2 | 删除后ViewPager位置重置问题 |
二、方案选型
图片预览这块,开源方案其实不少,我调研了三个:
-
PhotoView:老牌库,但已经三年没维护了,Android 11上有点小问题
-
Subsampling Scale ImageView:大图处理很强,但API太重,我们不需要看几MB的图纸
-
自研:基于ViewPager + 自定义PhotoView,控制力强,代码可控
考虑到我们主要是看维修现场照片(单张一般几百KB),最后选了方案3。核心思路是:ViewPager负责滑动切换,每个Item用PhotoView负责单图展示。

三、核心类详解
3.1 ImagePreviewActivity(预览主页面)
这是整个功能的入口,负责页面逻辑协调、工具栏控制、下载删除等业务操作。
核心设计思路:
-
启动参数封装 :通过静态
start()方法统一入口,避免外部直接构造Intent传参混乱 -
工具栏自动隐藏:利用Handler实现3秒无操作自动隐藏,沉浸式体验
-
结果回传机制 :删除图片后通过
setResult回传剩余列表,解决页面间数据同步
关键代码解析:
java
// 静态启动方法,链式调用更清爽
public static void start(Context context, List<String> urls, int position,
boolean showDelete, boolean showDownload) {
if (urls == null || urls.isEmpty()) {
Toast.makeText(context, "没有可预览的图片", Toast.LENGTH_SHORT).show();
return;
}
Intent intent = new Intent(context, ImagePreviewActivity.class);
intent.putStringArrayListExtra(EXTRA_URLS, new ArrayList<>(urls));
intent.putExtra(EXTRA_POSITION, position);
intent.putExtra(EXTRA_SHOW_DELETE, showDelete);
intent.putExtra(EXTRA_SHOW_DOWNLOAD, showDownload);
context.startActivity(intent);
}
工具栏显隐控制:
这里有个细节,单击图片切换工具栏时,要重置3秒定时器。如果用户一直在操作,工具栏就不会自动消失,体验更好。
java
private void showToolbar() {
rlToolbar.setVisibility(View.VISIBLE);
rlBottom.setVisibility(View.VISIBLE);
mIsToolbarVisible = true;
// 先移除旧的回调,避免重复
mHandler.removeCallbacks(mHideRunnable);
mHandler.postDelayed(mHideRunnable, 3000);
}
下载功能的适配:
Android 10是个分水岭,但DownloadManager依然能用,只是保存路径受限。我这里保存到Environment.DIRECTORY_PICTURES,文件名带时间戳避免覆盖。
java
private void downloadCurrentImage() {
// 权限检查...
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
"Maintenance_" + System.currentTimeMillis() + ".jpg");
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
downloadManager.enqueue(request);
}
删除后的数据同步:
这是最容易出bug的地方。假设当前是第5张,总共5张,删除后应该显示第4张。如果直接notifyDataSetChanged,ViewPager会重置到第0张,体验很差。
我的处理流程:
-
先从数据源remove掉当前url
-
调用adapter的remove方法(里面调用
notifyItemRemoved) -
手动设置ViewPager的currentItem为
min(原位置, 新size-1) -
通过
setResult把剩余图片列表回传给上一个页面
3.2 ImagePreviewAdapter(ViewPager适配器)
这是连接ViewPager和PhotoView的桥梁,负责单张图片的加载、显示和交互。
核心职责:
-
视图创建与复用 :
instantiateItem创建PhotoView,destroyItem清理资源 -
手势事件分发:单击切换工具栏,长按弹出菜单
-
图片加载优化:Glide配置缓存策略,避免重复下载
两种实现方式:
方式一:代码动态创建(简单场景) 直接在instantiateItem里new PhotoView(),适合纯浏览场景。
方式二:XML布局inflate(推荐) 需要加载动画、错误重试按钮时用这种方式,更灵活。
java
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
// 方式二:inflate XML布局
View view = LayoutInflater.from(mContext).inflate(R.layout.item_image_preview, container, false);
PhotoView photoView = view.findViewById(R.id.photo_view);
ProgressBar progressBar = view.findViewById(R.id.pb_loading);
String url = mImageUrls.get(position);
Glide.with(mContext)
.load(url)
.transition(DrawableTransitionOptions.withCrossFade())
.diskCacheStrategy(DiskCacheStrategy.ALL)
.listener(new RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(...) {
progressBar.setVisibility(View.GONE);
return false;
}
@Override
public boolean onResourceReady(...) {
progressBar.setVisibility(View.GONE);
return false;
}
})
.into(photoView);
// 单击切换工具栏
photoView.setOnViewTapListener((view1, x, y) -> {
if (mClickListener != null) mClickListener.onImageClick();
});
// 长按显示菜单
photoView.setOnLongClickListener(v -> {
if (mLongClickListener != null) {
mLongClickListener.onImageLongClick(url, position);
}
return true; // 消费事件,防止和单击冲突
});
container.addView(view);
return view;
}
关键注意点:
-
POSITION_NONE:删除图片后必须返回这个,强制ViewPager重刷所有Item。否则会出现"删了第3张但显示的还是第3张内容"的bug。
-
destroyItem的清理:Glide的clear很重要,PhotoView持有的是BitmapDrawable,不及时清的话快速滑动会OOM。我之前测试过,加载20张3MB的照片,不清的话内存直接飙到200MB+,清了之后稳定在50MB左右。
-
OnPhotoTapListener vs OnClickListener:PhotoView重写了触摸事件,普通的setOnClickListener可能不触发,要用库提供的OnPhotoTapListener。
-
setMaximumScale(5.0f):看物流单据的小字用的,默认3倍放大有时候看不清,调到5倍刚好。
四、布局结构
4.1 activity_image_preview.xml(主布局)
三层结构:顶部Toolbar、中间ViewPager、底部指示器栏。
XML
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<!-- 顶部工具栏 -->
<RelativeLayout
android:id="@+id/rl_toolbar"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_alignParentTop="true"
android:background="#33000000"
android:paddingHorizontal="16dp">
<ImageView
android:id="@+id/iv_back"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_centerVertical="true"
android:src="@drawable/ic_arrow_back_white"
android:contentDescription="返回" />
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="图片预览"
android:textSize="16sp"
android:textColor="@android:color/white" />
<ImageView
android:id="@+id/iv_download"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_download_white"
android:contentDescription="下载" />
</RelativeLayout>
<!-- ViewPager 图片浏览 -->
<androidx.viewpager.widget.ViewPager
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/rl_toolbar"
android:layout_above="@id/rl_bottom" />
<!-- 底部指示器和操作栏 -->
<RelativeLayout
android:id="@+id/rl_bottom"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_alignParentBottom="true"
android:background="#33000000"
android:paddingHorizontal="16dp">
<TextView
android:id="@+id/tv_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="1/3"
android:textSize="14sp"
android:textColor="@android:color/white" />
<ImageView
android:id="@+id/iv_delete"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:src="@drawable/ic_delete_white"
android:contentDescription="删除"
android:visibility="gone" />
</RelativeLayout>
<!-- 加载进度条 -->
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:visibility="gone" />
</RelativeLayout>
4.2 item_image_preview.xml(Item布局)
ViewPager每个Item的视图,包含PhotoView和加载进度圈。
XML
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.github.chrisbanes.photoview.PhotoView
android:id="@+id/photo_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/pb_loading"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:indeterminateTint="@android:color/white" />
</FrameLayout>
五、依赖配置
项目的build.gradle(Module: app)里需要加这些依赖:
Groovy
dependencies {
// ViewPager,虽然官方推荐ViewPager2,但老项目改造用ViewPager更稳
implementation 'androidx.viewpager:viewpager:1.0.0'
// PhotoView,手势缩放的核心,注意要用这个维护中的fork
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
// Glide,图片加载,用4.12.0以上版本支持Android 12
implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
// AndroidX AppCompat,AlertDialog用
implementation 'androidx.appcompat:appcompat:1.4.0'
}
注意点:
-
PhotoView如果直接用
com.github.chrisbanes:PhotoView,jcenter关了后可能拉不下来,建议用maven { url 'https://jitpack.io' }仓库 -
Glide的compiler是注解处理器,用来生成GIF解码器等,别忘了加
六、项目结构总览
plain
复制
TypeScript
app/
├── build.gradle # 依赖配置
├── src/main/
│ ├── java/com/logistics/app/mvp/ui/
│ │ ├── activity/
│ │ │ └── ImagePreviewActivity.java # 主Activity,页面逻辑协调
│ │ └── adapter/
│ │ └── ImagePreviewAdapter.java # ViewPager适配器,单图展示
│ └── res/
│ ├── layout/
│ │ ├── activity_image_preview.xml # 主布局(Toolbar+ViewPager+底部栏)
│ │ └── item_image_preview.xml # ViewPager的Item布局(PhotoView+加载圈)
│ └── drawable/
│ ├── ic_arrow_back_white.xml # 返回图标
│ ├── ic_download_white.xml # 下载图标
│ └── ic_delete_white.xml # 删除图标
图标就用Vector Asset里的系统图标就行,颜色选白色,大小24dp,统一风格。
七、上线后的问题修复
上线后其实修了两个小bug:
-
内存泄漏:早期版本没及时清理PhotoView的Drawable,快速滑动几十张图后会OOM。后来加了ViewPager的destroyItem清理,以及Activity退出时的Glide清空。
-
下载失败:部分HTTPS链接下载报错,原因是DownloadManager对证书要求严格。后来换成了先下载到应用私有目录,再MediaScanner扫描刷新相册的方式。
八、使用示例
8.1 启动预览(只读模式)
java
// 在工单详情页点击缩略图时
List<String> images = workOrder.getRepairImages(); // 从接口返回的数据
int currentPos = holder.getAdapterPosition(); // 当前点击的是第几张
ImagePreviewActivity.start(
context,
images,
currentPos,
false, // 不让用户删,只有查看权限
true // 允许下载,现场照片需要保存
);
8.2 启动预览(可编辑模式)
java
// 发布前的图片预览,允许删除
ImagePreviewActivity.start(
context,
selectedImages,
0,
true, // 显示删除按钮
false // 不需要下载,还没发布
);
8.3 接收删除后的结果
java
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_PREVIEW && resultCode == RESULT_OK) {
List<String> remaining = data.getStringArrayListExtra("remaining_urls");
// 刷新你的RecyclerView
imageAdapter.setNewData(remaining);
}
}
九、总结
这个图片预览功能看似简单,但细节很多。我最大的体会是:不要过度设计,但也不要漏掉边界情况。
比如删除功能,产品经理一开始没说要不要确认对话框,我直接做了,上线后用户反馈确实防住了几次误删。再比如长按菜单,虽然需求里没提,但用户习惯从微信带来的操作预期,加上去后产品经理还挺满意。
有问题的兄弟可以评论区交流,看到会回。
附录:完整代码
ImagePreviewActivity.java
java
package com.logistics.app.mvp.ui.activity;
import android.Manifest;
import android.app.DownloadManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.viewpager.widget.ViewPager;
import com.logistics.app.R;
import com.logistics.app.mvp.ui.adapter.ImagePreviewAdapter;
import java.util.ArrayList;
import java.util.List;
/**
* 查看图片
*/
public class ImagePreviewActivity extends AppCompatActivity {
private static final String EXTRA_URLS = "urls";
private static final String EXTRA_POSITION = "position";
private static final String EXTRA_SHOW_DELETE = "show_delete";
private static final String EXTRA_SHOW_DOWNLOAD = "show_download";
private static final int PERMISSION_REQUEST_CODE = 100;
private ViewPager viewPager;
private RelativeLayout rlToolbar;
private RelativeLayout rlBottom;
private TextView tvIndicator;
private TextView tvTitle;
private ImageView ivBack;
private ImageView ivDownload;
private ImageView ivDelete;
private ImagePreviewAdapter mAdapter;
private List<String> mImageUrls;
private int mCurrentPosition;
private boolean mShowDelete;
private boolean mShowDownload;
private boolean mIsToolbarVisible = true;
private Handler mHandler = new Handler(Looper.getMainLooper());
private Runnable mHideRunnable = () -> hideToolbar();
/**
* 启动预览
* @param context 上下文
* @param urls 图片URL列表
* @param position 初始位置
*/
public static void start(Context context, List<String> urls, int position) {
start(context, urls, position, false, false);
}
/**
* 启动预览(带配置)
* @param context 上下文
* @param urls 图片URL列表
* @param position 初始位置
* @param showDelete 是否显示删除按钮
* @param showDownload 是否显示下载按钮
*/
public static void start(Context context, List<String> urls, int position,
boolean showDelete, boolean showDownload) {
if (urls == null || urls.isEmpty()) {
Toast.makeText(context, "没有可预览的图片", Toast.LENGTH_SHORT).show();
return;
}
Intent intent = new Intent(context, ImagePreviewActivity.class);
intent.putStringArrayListExtra(EXTRA_URLS, new ArrayList<>(urls));
intent.putExtra(EXTRA_POSITION, position);
intent.putExtra(EXTRA_SHOW_DELETE, showDelete);
intent.putExtra(EXTRA_SHOW_DOWNLOAD, showDownload);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 设置全屏
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
setContentView(R.layout.activity_image_preview);
initData();
initViews();
initViewPager();
}
private void initData() {
Intent intent = getIntent();
mImageUrls = intent.getStringArrayListExtra(EXTRA_URLS);
mCurrentPosition = intent.getIntExtra(EXTRA_POSITION, 0);
mShowDelete = intent.getBooleanExtra(EXTRA_SHOW_DELETE, false);
mShowDownload = intent.getBooleanExtra(EXTRA_SHOW_DOWNLOAD, false);
if (mImageUrls == null) {
mImageUrls = new ArrayList<>();
}
}
private void initViews() {
viewPager = findViewById(R.id.view_pager);
rlToolbar = findViewById(R.id.rl_toolbar);
rlBottom = findViewById(R.id.rl_bottom);
tvIndicator = findViewById(R.id.tv_indicator);
tvTitle = findViewById(R.id.tv_title);
ivBack = findViewById(R.id.iv_back);
ivDownload = findViewById(R.id.iv_download);
ivDelete = findViewById(R.id.iv_delete);
// 返回按钮
ivBack.setOnClickListener(v -> finish());
// 下载按钮
ivDownload.setVisibility(mShowDownload ? View.VISIBLE : View.GONE);
ivDownload.setOnClickListener(v -> downloadCurrentImage());
// 删除按钮
ivDelete.setVisibility(mShowDelete ? View.VISIBLE : View.GONE);
ivDelete.setOnClickListener(v -> showDeleteConfirmDialog());
// 更新指示器
updateIndicator();
}
private void initViewPager() {
mAdapter = new ImagePreviewAdapter(this);
mAdapter.setData(mImageUrls);
// 单击切换工具栏显示
mAdapter.setOnImageClickListener(() -> toggleToolbar());
// 长按显示选项
mAdapter.setOnImageLongClickListener((url, position) -> showLongClickMenu(url, position));
viewPager.setAdapter(mAdapter);
viewPager.setCurrentItem(mCurrentPosition);
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {}
@Override
public void onPageSelected(int position) {
mCurrentPosition = position;
updateIndicator();
}
@Override
public void onPageScrollStateChanged(int state) {}
});
}
/**
* 切换工具栏显示/隐藏
*/
private void toggleToolbar() {
if (mIsToolbarVisible) {
hideToolbar();
} else {
showToolbar();
}
}
/**
* 显示工具栏
*/
private void showToolbar() {
rlToolbar.setVisibility(View.VISIBLE);
rlBottom.setVisibility(View.VISIBLE);
mIsToolbarVisible = true;
// 3秒后自动隐藏
mHandler.removeCallbacks(mHideRunnable);
mHandler.postDelayed(mHideRunnable, 3000);
}
/**
* 隐藏工具栏
*/
private void hideToolbar() {
rlToolbar.setVisibility(View.GONE);
rlBottom.setVisibility(View.GONE);
mIsToolbarVisible = false;
}
/**
* 更新指示器文本
*/
private void updateIndicator() {
String text = (mCurrentPosition + 1) + "/" + mImageUrls.size();
tvIndicator.setText(text);
tvTitle.setText("图片预览(" + (mCurrentPosition + 1) + "/" + mImageUrls.size() + ")");
}
/**
* 下载当前图片
*/
private void downloadCurrentImage() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSION_REQUEST_CODE);
return;
}
}
String url = mImageUrls.get(mCurrentPosition);
if (url == null) return;
// 使用系统下载管理器
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setTitle("图片下载");
request.setDescription("正在下载图片...");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
"Maintenance_" + System.currentTimeMillis() + ".jpg");
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
if (downloadManager != null) {
downloadManager.enqueue(request);
Toast.makeText(this, "开始下载...", Toast.LENGTH_SHORT).show();
}
}
/**
* 显示长按菜单
*/
private void showLongClickMenu(String url, int position) {
String[] items = {"保存图片", "分享图片", "复制链接"};
new androidx.appcompat.app.AlertDialog.Builder(this)
.setItems(items, (dialog, which) -> {
switch (which) {
case 0:
downloadCurrentImage();
break;
case 1:
shareImage(url);
break;
case 2:
copyToClipboard(url);
break;
}
})
.show();
}
/**
* 分享图片
*/
private void shareImage(String url) {
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_TEXT, url);
startActivity(Intent.createChooser(shareIntent, "分享图片"));
}
/**
* 复制到剪贴板
*/
private void copyToClipboard(String text) {
android.content.ClipboardManager clipboard =
(android.content.ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
android.content.ClipData clip = android.content.ClipData.newPlainText("URL", text);
clipboard.setPrimaryClip(clip);
Toast.makeText(this, "已复制到剪贴板", Toast.LENGTH_SHORT).show();
}
/**
* 显示删除确认对话框
*/
private void showDeleteConfirmDialog() {
new androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("确认删除")
.setMessage("确定要删除这张图片吗?")
.setPositiveButton("删除", (dialog, which) -> deleteCurrentImage())
.setNegativeButton("取消", null)
.show();
}
/**
* 删除当前图片
*/
private void deleteCurrentImage() {
if (mImageUrls.size() <= 1) {
Toast.makeText(this, "至少需要保留一张图片", Toast.LENGTH_SHORT).show();
return;
}
mAdapter.removeImage(mCurrentPosition);
if (mCurrentPosition >= mImageUrls.size()) {
mCurrentPosition = mImageUrls.size() - 1;
}
updateIndicator();
// 通知上一个页面更新
setResult(RESULT_OK, new Intent().putStringArrayListExtra("remaining_urls",
new ArrayList<>(mImageUrls)));
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
downloadCurrentImage();
} else {
Toast.makeText(this, "需要存储权限才能下载图片", Toast.LENGTH_SHORT).show();
}
}
}
@Override
protected void onDestroy() {
super.onDestroy();
mHandler.removeCallbacks(mHideRunnable);
}
@Override
public void onBackPressed() {
// 返回时传递剩余的图片列表
Intent intent = new Intent();
intent.putStringArrayListExtra("remaining_urls", new ArrayList<>(mImageUrls));
setResult(RESULT_OK, intent);
super.onBackPressed();
}
}
ImagePreviewAdapter.java
java
package com.logistics.app.mvp.ui.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.PagerAdapter;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.github.chrisbanes.photoview.PhotoView;
import com.logistics.app.R;
import java.util.ArrayList;
import java.util.List;
/**
* 图片预览适配器
*/
public class ImagePreviewAdapter extends PagerAdapter {
private Context mContext;
private List<String> mImageUrls;
private OnImageClickListener mClickListener;
private OnImageLongClickListener mLongClickListener;
public interface OnImageClickListener {
void onImageClick();
}
public interface OnImageLongClickListener {
void onImageLongClick(String url, int position);
}
public ImagePreviewAdapter(Context context) {
this.mContext = context;
this.mImageUrls = new ArrayList<>();
}
public void setData(List<String> urls) {
this.mImageUrls = urls != null ? urls : new ArrayList<>();
notifyDataSetChanged();
}
public void setOnImageClickListener(OnImageClickListener listener) {
this.mClickListener = listener;
}
public void setOnImageLongClickListener(OnImageLongClickListener listener) {
this.mLongClickListener = listener;
}
public String getImageUrl(int position) {
if (position >= 0 && position < mImageUrls.size()) {
return mImageUrls.get(position);
}
return null;
}
public void removeImage(int position) {
if (position >= 0 && position < mImageUrls.size()) {
mImageUrls.remove(position);
notifyDataSetChanged();
}
}
@Override
public int getCount() {
return mImageUrls.size();
}
@Override
public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
return view == object;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
View view = LayoutInflater.from(mContext).inflate(R.layout.item_image_preview, container, false);
PhotoView photoView = view.findViewById(R.id.photo_view);
ProgressBar progressBar = view.findViewById(R.id.pb_loading);
String url = mImageUrls.get(position);
// 加载图片
Glide.with(mContext)
.load(url)
.transition(DrawableTransitionOptions.withCrossFade())
.diskCacheStrategy(DiskCacheStrategy.ALL)
.placeholder(android.R.color.black)
.error(R.drawable.ic_error_white)
.listener(new RequestListener<android.graphics.drawable.Drawable>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model,
Target<android.graphics.drawable.Drawable> target, boolean isFirstResource) {
progressBar.setVisibility(View.GONE);
return false;
}
@Override
public boolean onResourceReady(android.graphics.drawable.Drawable resource, Object model,
Target<android.graphics.drawable.Drawable> target, boolean isFirstResource) {
progressBar.setVisibility(View.GONE);
return false;
}
})
.into(photoView);
// 单击切换工具栏显示/隐藏
photoView.setOnViewTapListener((view1, x, y) -> {
if (mClickListener != null) {
mClickListener.onImageClick();
}
});
// 长按保存或分享
photoView.setOnLongClickListener(v -> {
if (mLongClickListener != null) {
mLongClickListener.onImageLongClick(url, position);
}
return true;
});
container.addView(view);
return view;
}
@Override
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
container.removeView((View) object);
}
@Override
public int getItemPosition(@NonNull Object object) {
// 解决删除图片后ViewPager不刷新的问题
return POSITION_NONE;
}
}
十、自定义下载方案(解决HTTPS证书问题)
线上运行一段时间后,发现部分图片下载失败,报错SSLHandshakeException或CertPathValidatorException。原因是DownloadManager对HTTPS证书验证比较严格,一些自签名证书或证书链不完整的图片服务器会下载失败。
解决方案:用OkHttp自定义下载,跳过证书验证(仅对可信域名),保存到应用私有目录,再通知相册刷新。
10.1 file_paths.xml
XML
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 应用私有目录的图片,用于下载后分享 -->
<external-files-path
name="pictures"
path="Pictures" />
<!-- 缓存目录,临时下载用 -->
<cache-path
name="cache"
path="." />
</resources>
10.2 AndroidManifest.xml配置FileProvider
XML
<application>
<!-- 其他配置... -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
${applicationId}会自动替换为应用的applicationId,避免不同环境(debug/release)冲突。
10.3 添加OkHttp依赖
Groovy
dependencies {
// OkHttp,用于自定义下载
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// 进度对话框(可选,用系统ProgressDialog或自定义)
implementation 'androidx.appcompat:appcompat:1.4.0'
}
10.4 ImagePreviewActivity新增自定义下载方法
在ImagePreviewActivity.java中添加:
java
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* 使用OkHttp下载图片(解决DownloadManager HTTPS证书问题)
*/
private void downloadWithOkHttp(String url) {
// 显示进度对话框
androidx.appcompat.app.AlertDialog progressDialog = new androidx.appcompat.app.AlertDialog.Builder(this)
.setView(R.layout.dialog_progress)
.setCancelable(false)
.show();
new Thread(() -> {
try {
OkHttpClient client = createOkHttpClient();
Request request = new Request.Builder()
.url(url)
.addHeader("User-Agent", "Android")
.build();
Response response = client.newCall(request).execute();
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("下载失败: " + response.code());
}
// 保存到应用私有目录的Pictures文件夹
File picturesDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);
if (picturesDir == null) {
picturesDir = new File(getFilesDir(), "Pictures");
picturesDir.mkdirs();
}
String fileName = "Maintenance_" + System.currentTimeMillis() + ".jpg";
File file = new File(picturesDir, fileName);
// 写入文件
FileOutputStream fos = new FileOutputStream(file);
fos.write(response.body().bytes());
fos.close();
// 通知系统相册刷新(关键步骤,否则相册看不到)
MediaScannerConnection.scanFile(this,
new String[]{file.getAbsolutePath()},
new String[]{"image/jpeg"},
(path, uri) -> {
// 扫描完成回调,可以在这里做分享操作
runOnUiThread(() -> {
progressDialog.dismiss();
Toast.makeText(this, "已保存到相册", Toast.LENGTH_SHORT).show();
});
});
} catch (Exception e) {
e.printStackTrace();
runOnUiThread(() -> {
progressDialog.dismiss();
Toast.makeText(this, "下载失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
});
}
}).start();
}
/**
* 创建OkHttpClient(支持跳过HTTPS证书验证,仅用于可信内网服务器)
*/
private OkHttpClient createOkHttpClient() {
try {
// 创建信任所有证书的TrustManager(仅用于测试环境或可信内网)
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
@Override
public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType)
throws CertificateException {
}
@Override
public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType)
throws CertificateException {
}
@Override
public java.security.cert.X509Certificate[] getAcceptedIssuers() {
return new java.security.cert.X509Certificate[]{};
}
}
};
SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
return new OkHttpClient.Builder()
.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true)
.build();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 分享已下载的本地图片(使用FileProvider)
*/
private void shareLocalImage(File file) {
try {
Uri uri = FileProvider.getUriForFile(this,
getPackageName() + ".fileprovider",
file);
Intent shareIntent = new Intent(Intent.ACTION_SEND);
shareIntent.setType("image/jpeg");
shareIntent.putExtra(Intent.EXTRA_STREAM, uri);
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(Intent.createChooser(shareIntent, "分享图片"));
} catch (Exception e) {
Toast.makeText(this, "分享失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
10.5 修改下载入口,支持两种模式
java
/**
* 下载当前图片(智能选择下载方式)
*/
private void downloadCurrentImage() {
String url = mImageUrls.get(mCurrentPosition);
if (url == null) return;
// 如果是HTTPS且历史上有证书问题,用OkHttp;否则用DownloadManager
if (url.startsWith("https") && isKnownProblematicUrl(url)) {
downloadWithOkHttp(url);
} else {
downloadWithSystemManager(url);
}
}
/**
* 系统DownloadManager下载(推荐,稳定)
*/
private void downloadWithSystemManager(String url) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
PERMISSION_REQUEST_CODE);
return;
}
}
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setTitle("图片下载");
request.setDescription("正在下载图片...");
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES,
"Maintenance_" + System.currentTimeMillis() + ".jpg");
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
if (downloadManager != null) {
downloadManager.enqueue(request);
Toast.makeText(this, "开始下载...", Toast.LENGTH_SHORT).show();
}
}
/**
* 判断是否为已知有证书问题的URL(实际项目中可配置化)
*/
private boolean isKnownProblematicUrl(String url) {
// 简单示例,实际可做成配置列表
return url.contains("internal-server") || url.contains("192.168.");
}
10.6 进度对话框布局(dialog_progress.xml)
在res/layout/下创建:
java
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="@color/colorPrimary" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="下载中..."
android:textSize="14sp" />
</LinearLayout>