Android图片预览功能实战:从需求到上线的完整方案

前言

最近在做物流App的工单模块,产品经理提了个需求:用户上传的维修照片要能点击查看大图,支持左右滑动切换,还要能下载保存。听起来简单,但真动手做的时候发现坑不少------ViewPager的缓存机制、手势冲突、权限适配、甚至删除后的数据同步都是问题。

这篇文章记录了我完整的实现过程,包括踩过的坑和最终选定的方案。代码已经在线上跑了半年多,基本稳定,分享出来给有同样需求的兄弟参考。


一、需求拆解

刚开始接到需求时,我先把功能点拆了一下:

表格

复制

功能点 优先级 难点
多图左右滑动浏览 P0 ViewPager的缓存和复用
单击隐藏/显示工具栏 P0 与图片缩放手势的冲突
图片下载到本地 P1 Android 10+分区存储适配
长按菜单(保存/分享/复制链接) P1 弹窗时机和坐标计算
删除图片并同步 P2 删除后ViewPager位置重置问题

二、方案选型

图片预览这块,开源方案其实不少,我调研了三个:

  1. PhotoView:老牌库,但已经三年没维护了,Android 11上有点小问题

  2. Subsampling Scale ImageView:大图处理很强,但API太重,我们不需要看几MB的图纸

  3. 自研:基于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张,体验很差。

我的处理流程:

  1. 先从数据源remove掉当前url

  2. 调用adapter的remove方法(里面调用notifyItemRemoved

  3. 手动设置ViewPager的currentItem为min(原位置, 新size-1)

  4. 通过setResult把剩余图片列表回传给上一个页面


3.2 ImagePreviewAdapter(ViewPager适配器)

这是连接ViewPager和PhotoView的桥梁,负责单张图片的加载、显示和交互。

核心职责:

  • 视图创建与复用instantiateItem创建PhotoView,destroyItem清理资源

  • 手势事件分发:单击切换工具栏,长按弹出菜单

  • 图片加载优化:Glide配置缓存策略,避免重复下载

两种实现方式:

方式一:代码动态创建(简单场景) 直接在instantiateItemnew 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;
}

关键注意点:

  1. POSITION_NONE:删除图片后必须返回这个,强制ViewPager重刷所有Item。否则会出现"删了第3张但显示的还是第3张内容"的bug。

  2. destroyItem的清理:Glide的clear很重要,PhotoView持有的是BitmapDrawable,不及时清的话快速滑动会OOM。我之前测试过,加载20张3MB的照片,不清的话内存直接飙到200MB+,清了之后稳定在50MB左右。

  3. OnPhotoTapListener vs OnClickListener:PhotoView重写了触摸事件,普通的setOnClickListener可能不触发,要用库提供的OnPhotoTapListener。

  4. 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:

  1. 内存泄漏:早期版本没及时清理PhotoView的Drawable,快速滑动几十张图后会OOM。后来加了ViewPager的destroyItem清理,以及Activity退出时的Glide清空。

  2. 下载失败:部分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证书问题)

线上运行一段时间后,发现部分图片下载失败,报错SSLHandshakeExceptionCertPathValidatorException。原因是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>
相关推荐
如此风景4 小时前
kotlin协程学习小计
android·kotlin
轩情吖5 小时前
MySQL初识
android·数据库·sql·mysql·adb·存储引擎
Sun_gentle5 小时前
android studio创建flutter项目
android·flutter·android studio
我命由我123455 小时前
在 Android Studio 中,新建 AIDL 文件按钮是灰色
android·ide·android studio·安卓·android jetpack·android-studio·android runtime
音视频牛哥5 小时前
Android平台RTMP/RTSP超低延迟直播播放器开发详解——基于SmartMediaKit深度实践
android·人工智能·计算机视觉·音视频·rtmp播放器·安卓rtmp播放器·rtmp直播播放器
麻瓜生活睁不开眼5 小时前
Android 14 开机自启动第三方 APK 全流程踩坑与最终解决方案(含 RescueParty 避坑)
android·java·深度学习
轩情吖6 小时前
MySQL库的操作
android·数据库·mysql·oracle·字符集·数据库操作·编码集
贤泽6 小时前
Android15 ContentProvider 深度源码分析(上)
android·aosp