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>
相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui