低成本实现媒体文件预览

在移动应用开发中,媒体文件的预览功能是不可或缺的一部分。无论是图片、视频还是音频文件,都需要一个高效且易用的解决方案来满足用户需求。本文将介绍两个常用的开源库:com.github.chrisbanes:PhotoViewExoPlayer,并探讨它们在媒体文件预览场景中的优劣及待优化的地方。


一、com.github.chrisbanes:PhotoView

简介

com.github.chrisbanes:PhotoView 是一个用于 Android 的开源库,提供了增强的 ImageView 功能,支持手势缩放、平移等交互操作。它非常适合用于图片预览场景,例如相册应用、图片详情页等。

主要功能

  1. 手势缩放和平移
    支持双指缩放和单指拖动图片的功能。
  2. 多种缩放类型
    提供了不同的缩放模式(如 FIT_CENTER, CENTER_CROP 等),可以根据需求设置图片的显示方式。
  3. 简单易用
    只需将 PhotoView 替换掉普通的 ImageView,即可实现丰富的交互功能。
  4. 兼容性好
    兼容大多数 Android 版本,适合老旧设备和现代设备使用。

优势

  • 轻量级:代码简洁,依赖少,易于集成。
  • 性能优越:手势操作流畅,内存占用低。
  • 扩展性强:可以通过自定义实现更多功能,如加载 GIF 图片或网络图片。

待优化地方

  1. 手势控制
    默认的手势识别逻辑可能无法完全满足复杂场景需求(如多点触控冲突)。开发者需要根据具体需求进行调整。
  2. 动画效果
    缺乏内置的过渡动画支持,可能需要额外引入动画库来提升用户体验。
  3. 图片加载优化
    对于大图或高分辨率图片,可能会出现内存溢出问题,建议结合图片加载库(如 Glide 或 Picasso)使用。

二、ExoPlayer

简介

ExoPlayer 是一款基于 Android 中的低层级媒体 API 构建的应用级媒体播放器。与 Android 内置的 MediaPlayer 相比,ExoPlayer 具有多项优势。它支持 MediaPlayer 支持的许多媒体格式,还支持 DASH 和 SmoothStreaming 等自适应格式。ExoPlayer 具有高度的可定制性和可扩展性,因此能够用于许多高级用例。它是 Google 应用(包括 YouTube 和 Google Play 影视)使用的开源项目。

官方文档

ExoPlayer官方文档

主要功能

  1. 支持多种媒体格式
    包括常见的 MP4、MKV、FLV 等格式,以及自适应流媒体格式(DASH、SmoothStreaming)。
  2. 高度可定制
    提供灵活的组件化设计,允许开发者根据需求定制播放器行为。
  3. 跨平台支持
    兼容 Android 4.1+,适用于广泛的设备。
  4. 错误恢复机制
    内置强大的错误处理和恢复机制,确保播放稳定性。

优势

  • 高性能:支持硬件加速解码,降低 CPU 负载。
  • 灵活性:支持自定义渲染器、数据源和解码器。
  • 社区活跃:拥有庞大的开发者社区和丰富的第三方插件。

待优化地方

  1. 初始化时间较长
    在某些情况下,ExoPlayer 的初始化时间可能较长,影响用户体验。
  2. 内存占用较高
    对于低端设备,可能存在内存不足的问题,需要优化资源管理。
  3. 复杂性较高
    配置和使用相对复杂,新手可能需要花费较多时间学习。

三、实现示例

以下是一个完整的媒体文件预览实现示例,包含所有关键代码和资源文件:

1. 主活动 PreviewActivity

java 复制代码
package com.wangyou.preview;

import android.content.Intent;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.TextView;

import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.viewpager2.widget.ViewPager2;

import java.io.File;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class PreviewActivity extends AppCompatActivity {
    private static final String TAG = "PreviewActivity";

    private ImageView iv_back, iv_more;
    private List<String> media_paths = new ArrayList<>();
    private PopupWindow more_info_popup;
    private TextView tv_popup_details, tv_popup_file_name, tv_popup_file_path, tv_popup_modified_time, tv_title;
    private ViewPager2 view_pager;
    private Viewpager2Adapter viewpager2_adapter;

    // 初始化
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_preview);
        Log.d(TAG, "onCreate called");

        initView();
        initViewPager();
        overridePendingTransition(R.anim.fade_anim_in, R.anim.fade_anim_out);
        getWindow().setStatusBarColor(ContextCompat.getColor(this, R.color.main_theme_bg));
    }

    // 释放资源,暂停播放器
    @Override
    protected void onDestroy() {
        super.onDestroy();
        Log.d(TAG, "onDestroy called");
        if (viewpager2_adapter != null) {
            viewpager2_adapter.releaseAllPlayers();
        }
    }

    // 暂停播放器
    @Override
    protected void onPause() {
        super.onPause();
        Log.d(TAG, "onPause called");
        if (viewpager2_adapter != null) {
            viewpager2_adapter.pausePlayer();
        }
    }

    // 处理返回键事件,暂停播放器并关闭页面
    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK) {
            Log.d(TAG, "Back button pressed");
            if (viewpager2_adapter != null) {
                viewpager2_adapter.pausePlayer();
            }
            finish();
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    // 关闭页面并添加过渡动画
    @Override
    public void finish() {
        super.finish();
        Log.d(TAG, "finish called");
        overridePendingTransition(R.anim.fade_anim_in, R.anim.fade_anim_out);
    }

    // 调整弹出窗口的宽度以适应内容
    private void adjustPopupWidth(TextView file_name_text_view, TextView file_path_text_view) {
        int file_name_width = (int) file_name_text_view.getPaint().measureText(file_name_text_view.getText().toString());
        int file_path_width = (int) file_path_text_view.getPaint().measureText(file_path_text_view.getText().toString());
        int screen_width = Utils.getRealScreenWidthPx(this);
        int total_width = Math.max(file_name_width, file_path_width);
        int default_width = Utils.dpToPx(this, 300);

        int max_width = Math.min(total_width, screen_width);
        max_width = Math.max(max_width, default_width);

        file_name_text_view.setMaxWidth(max_width);
        file_path_text_view.setMaxWidth(max_width);
        more_info_popup.setWidth(max_width);
    }

    // 格式化视频时长
    private String formatDuration(long duration_millis) {
        long hours = duration_millis / 3600000;
        long minutes = (duration_millis % 3600000) / 60000;
        long seconds = (duration_millis % 60000) / 1000;
        return String.format("%02d:%02d:%02d", hours, minutes, seconds);
    }

    // 格式化文件大小
    private String formatFileSize(long size) {
        if (size <= 0) return "0";
        final String[] units = new String[]{"B", "KB", "MB", "GB", "TB"};
        int digit_groups = (int) (Math.log10(size) / Math.log10(1024));
        return new DecimalFormat("#,##0.#").format(size / Math.pow(1024, digit_groups)) + " " + units[digit_groups];
    }

    // 从Intent中获取文件路径
    private String getFilePathFromIntent(Intent intent) {
        if (intent != null) {
            String path = intent.getStringExtra("filePath");
            if (TextUtils.isEmpty(path)) {
                Uri file_uri = intent.getData();
                if (file_uri != null) {
                    return file_uri.getPath();
                }
            }
            return path;
        }
        return null;
    }

    // 获取文件详细信息
    private String getFileDetails(String file_path) {
        if (TextUtils.isEmpty(file_path)) {
            return "";
        }
        File file = new File(file_path);
        if (!file.exists()) {
            return "";
        }
        if (Utils.isImage(file_path)) {
            return getImageDetails(file);
        } else if (Utils.isVideo(file_path)) {
            return getVideoDetails(file);
        } else if (Utils.isMusic(file_path)) {
            return getMusicDetails(file);
        }
        return "";
    }

    // 获取文件最后修改时间
    private String getFileTime(File file) {
        long last_modified = file.lastModified();
        Date date = new Date(last_modified);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.format(date);
    }

    // 从Intent中获取文件路径列表
    private List<String> getFilePathsWrapper(Intent intent) {
        if (intent == null) {
            return new ArrayList<>();
        }
        return intent.getStringArrayListExtra("filePathsWrapper");
    }

    // 获取视频文件详细信息
    private String getVideoDetails(File file) {
        MediaMetadataRetriever retriever = new MediaMetadataRetriever();
        retriever.setDataSource(file.getAbsolutePath());
        String width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
        String height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
        String duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
        long size = file.length();
        retriever.release();
        return width + "x" + height + " | " + formatFileSize(size) + " | " + formatDuration(Long.parseLong(duration));
    }

    // 获取图片文件详细信息
    private String getImageDetails(File file) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile(file.getAbsolutePath(), options);
        int width = options.outWidth;
        int height = options.outHeight;
        long size = file.length();
        return width + "x" + height + " | " + formatFileSize(size);
    }

    // 获取音乐文件详细信息
    private String getMusicDetails(File file) {
        MediaMetadataRetriever retriever = new MediaMetadataRetriever();
        retriever.setDataSource(file.getAbsolutePath());
        String duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
        long size = file.length();
        retriever.release();
        return formatFileSize(size) + " | " + formatDuration(Long.parseLong(duration));
    }


    // 初始化视图组件
    private void initView() {
        iv_back = findViewById(R.id.iv_preview_back);
        tv_title = findViewById(R.id.tv_preview_title);
        iv_more = findViewById(R.id.iv_preview_more);
        view_pager = findViewById(R.id.preview_viewpager);
        iv_back.setOnClickListener(view -> finish());
        iv_more.setOnClickListener(v -> showMoreInfoPopup());
    }

    // 初始化ViewPager
    private void initViewPager() {
        String file_path = getFilePathFromIntent(getIntent());
        Utils.checkMediaPause(file_path);
        if (TextUtils.isEmpty(file_path)) {
            return;
        }
        File file = new File(file_path);
        if (!file.exists()) {
            return;
        }
        media_paths = getFilePathsWrapper(getIntent());
        // 设置初始位置为当前文件
        int initial_position = media_paths.indexOf(file.getAbsolutePath());
        tv_title.setText(file.getName());
        viewpager2_adapter = new Viewpager2Adapter(media_paths, view_pager);
        view_pager.setAdapter(viewpager2_adapter);
        view_pager.setCurrentItem(initial_position, false);
        view_pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
            @Override
            public void onPageSelected(int position) {
                super.onPageSelected(position);
                if (media_paths != null && position >= 0 && position < media_paths.size()) {
                    String current_file_path = media_paths.get(position);
                    File current_file = new File(current_file_path);
                    tv_title.setText(current_file.getName());
                    // 更新 Adapter 的当前播放位置
                    //fix:Scroll callbacks might be run during a measure & layout pass where you cannot change the RecyclerView data
                    view_pager.post(() -> {
                        if (viewpager2_adapter != null) {
                            viewpager2_adapter.setCurrentPlayingPosition(position);
                        }
                    });
                }
            }
        });
    }

    // 设置弹出窗口的详细信息
    private void setPopupViewDetails(View popup_view) {
        String current_file_path = media_paths.get(view_pager.getCurrentItem());
        File file = new File(current_file_path);
        if (!file.exists()) {
            return;
        }

        tv_popup_file_name = popup_view.findViewById(R.id.popup_file_name);
        tv_popup_file_path = popup_view.findViewById(R.id.popup_file_path);
        tv_popup_modified_time = popup_view.findViewById(R.id.popup_file_modified_time);
        tv_popup_details = popup_view.findViewById(R.id.popup_file_details);

        tv_popup_file_name.setText(file.getName());
        tv_popup_file_path.setText(current_file_path);
        tv_popup_modified_time.setText(getFileTime(file));
        tv_popup_details.setText(getFileDetails(current_file_path));

        adjustPopupWidth(tv_popup_file_name, tv_popup_file_path);

        more_info_popup.update();
    }

    // 显示更多信息弹出窗口
    private void showMoreInfoPopup() {
        View popup_view = LayoutInflater.from(this).inflate(R.layout.preview_more_info, null);
        more_info_popup = new PopupWindow(popup_view, ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT, true);
        more_info_popup.setOutsideTouchable(true);
        setPopupViewDetails(popup_view);
        more_info_popup.showAsDropDown(iv_more);
        more_info_popup.setAnimationStyle(R.style.PopupAnimation);
    }

    
    private int getRealScreenWidthPx(PreviewActivity context) {
        return context.getResources().getDisplayMetrics().widthPixels;
    }

    private int dpToPx(PreviewActivity context, int dp) {
        float density = context.getResources().getDisplayMetrics().density;
        return Math.round(dp * density);
    }
}

2. ViewPager2 适配器

java 复制代码
package com.wangyou.preview;

import android.annotation.SuppressLint;
import android.net.Uri;
import android.util.Log;
import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;

import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;

import com.github.chrisbanes.photoview.PhotoView;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ui.PlayerView;

import java.util.ArrayList;
import java.util.List;
/**
* viewpager适配器,管理图片查看和音视频播放
*/
public class Viewpager2Adapter extends RecyclerView.Adapter<Viewpager2Adapter.MediaViewHolder> {
    private final String TAG = "Viewpager2Adapter";
    private final List<MediaViewHolder> viewHolders = new ArrayList<>();
    private int currentPlayingPosition = -1;
    private List<String> mediaPaths; // 更改参数命名
    private ViewPager2 viewPager; // 更改参数命名

    /**
     * 构造函数,初始化媒体路径列表和ViewPager2实例。
     * @param mediaPaths 媒体文件路径列表
     * @param viewPager ViewPager2实例
     */
    public Viewpager2Adapter(List<String> mediaPaths, ViewPager2 viewPager) {
        this.mediaPaths = mediaPaths;
        this.viewPager = viewPager;
    }

    /**
     * 创建ViewHolder实例。
     * @param parent 父视图组
     * @param viewType 视图类型
     * @return 新创建的ViewHolder实例
     */
    @NonNull
    @Override
    public MediaViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        Log.d(TAG, "onCreateViewHolder called");
        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.preview_media_item, null);
        ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
        view.setLayoutParams(params);
        return new MediaViewHolder(view);
    }

    /**
     * 绑定ViewHolder到指定位置的数据。
     * @param mediaViewHolder ViewHolder实例
     * @param position 数据位置
     */
    @Override
    public void onBindViewHolder(@NonNull MediaViewHolder mediaViewHolder, int position) {
        Log.d(TAG, "onBindViewHolder called with position: " + position);
        // 获取媒体路径
        String mediaPath = mediaPaths.get(position);
        // 根据媒体类型绑定数据
        if (Utils.isImage(mediaPath)) {
            mediaViewHolder.bindImage(mediaPath);
        } else if (Utils.isVideo(mediaPath) || Utils.isMusic(mediaPath)) {
            mediaViewHolder.bindVideo(mediaPath);

            // 获取ViewHolder的适配器位置
            int adapterPosition = mediaViewHolder.getAdapterPosition();
            if (adapterPosition != RecyclerView.NO_POSITION) {
                // 如果当前项是ViewPager2的当前项,则播放视频
                if (adapterPosition == viewPager.getCurrentItem()) {
                    mediaViewHolder.playVideo();
                    currentPlayingPosition = adapterPosition;
                } else {
                    // 否则暂停视频
                    mediaViewHolder.pauseVideo();
                }
            }
        }

        // 如果ViewHolder列表中不包含当前ViewHolder,则添加
        if (!viewHolders.contains(mediaViewHolder)) {
            viewHolders.add(mediaViewHolder);
        }
    }

    @Override
    public void onViewDetachedFromWindow(@NonNull MediaViewHolder holder) {
        Log.d(TAG, "onViewDetachedFromWindow called");
        super.onViewDetachedFromWindow(holder);
        // 释放播放器资源
        holder.releasePlayer();
    }

    @Override
    public void onViewRecycled(@NonNull MediaViewHolder holder) {
        Log.d(TAG, "onViewRecycled called");
        super.onViewRecycled(holder);
        // 从ViewHolder列表中移除当前ViewHolder
        viewHolders.remove(holder);
        // 释放播放器资源
        holder.releasePlayer();
    }

    @Override
    public int getItemCount() {
        return mediaPaths.size();
    }

    /**
     * 设置当前播放位置。
     * @param position 播放位置
     */
    public void setCurrentPlayingPosition(int position) {
        // 记录日志
        Log.d(TAG, "setCurrentPlayingPosition called with position: " + position);
        // 如果当前播放位置与新位置不同,则更新播放位置
        if (currentPlayingPosition != position) {
            // 记录之前的播放位置
            int previousPosition = currentPlayingPosition;
            // 更新当前播放位置
            currentPlayingPosition = position;
            // 如果之前的位置有效,则通知该位置的项已更改
            if (previousPosition != -1) {
                notifyItemChanged(previousPosition, "");
            }
            // 通知当前位置的项已更改
            notifyItemChanged(currentPlayingPosition, "");
        }
    }

    public void pausePlayer() {
        // 记录日志
        Log.d(TAG, "pausePlayer called");
        // 遍历所有ViewHolder并暂停播放
        for (MediaViewHolder holder : viewHolders) {
            if (holder != null && holder.mIsPlaying) {
                holder.pauseVideo();
            }
        }
    }

    public void playPlayer() {
        // 记录日志
        Log.d(TAG, "playPlayer called");
        // 如果当前播放位置有效且在ViewHolder范围内,则播放视频
        if (currentPlayingPosition != -1 && currentPlayingPosition < viewHolders.size()) {
            MediaViewHolder holder = viewHolders.get(currentPlayingPosition);
            if (holder != null) {
                holder.playVideo();
            }
        }
    }

    public void releaseAllPlayers() {
        // 记录日志
        Log.d(TAG, "releaseAllPlayers called");
        // 遍历所有ViewHolder并释放播放器资源
        for (MediaViewHolder holder : viewHolders) {
            if (holder != null) {
                holder.releasePlayer();
            }
        }
        // 清空ViewHolder列表
        viewHolders.clear();
    }

    public static class MediaViewHolder extends RecyclerView.ViewHolder {
        private final String TAG = "MediaViewHolder";
        private final PhotoView photoView;
        private final PlayerView playerView;
        private ExoPlayer player;
        private boolean mIsPlaying;
        private ImageButton exoPlayBtn, exoPauseBtn;
        private GestureDetector gestureDetector;

        /**
         * 构造函数,初始化ViewHolder中的视图组件。
         * @param itemView 根视图
         */
        @SuppressLint("ClickableViewAccessibility")
        public MediaViewHolder(@NonNull View itemView) {
            super(itemView);
            // 初始化PhotoView
            photoView = itemView.findViewById(R.id.preview_photo_view);
            // 初始化PlayerView
            playerView = itemView.findViewById(R.id.preview_player_view);
            // 初始化播放按钮
            exoPlayBtn = playerView.findViewById(R.id.exo_play);
            exoPauseBtn = playerView.findViewById(R.id.exo_pause);

            // 初始化手势检测器
            gestureDetector = new GestureDetector(itemView.getContext(), new GestureDetector.SimpleOnGestureListener() {
                @Override
                public boolean onSingleTapConfirmed(MotionEvent e) {
                    toggleControlsVisibility();
                    return true;
                }

                @Override
                public boolean onDoubleTap(MotionEvent e) {
                    togglePlayPause();
                    exoPlayBtn.setVisibility(View.VISIBLE);
                    exoPauseBtn.setVisibility(View.GONE);
                    return true;
                }
            });

            // 设置触摸监听器
            playerView.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
        }

        /**
         * 绑定图片到PhotoView。
         * @param imagePath 图片路径
         */
        void bindImage(String imagePath) {
            // 记录日志
            Log.d(TAG, "bindImage called with imagePath: " + imagePath);
            // 隐藏PlayerView并显示PhotoView
            playerView.setVisibility(View.GONE);
            photoView.setVisibility(View.VISIBLE);
            // 设置图片URI
            photoView.setImageURI(Uri.parse(imagePath));
        }

        /**
         * 绑定视频到PlayerView。
         * @param videoPath 视频路径
         */
        void bindVideo(String videoPath) {
            // 记录日志
            Log.d(TAG, "bindVideo called with videoPath: " + videoPath);
            // 隐藏PhotoView并显示PlayerView
            photoView.setVisibility(View.GONE);
            playerView.setVisibility(View.VISIBLE);
            // 如果是音乐文件,则设置默认艺术作品
            if (Utils.isMusic(videoPath)) {
                playerView.setDefaultArtwork(ContextCompat.getDrawable(itemView.getContext(), R.drawable.app_icon));
            }
            // 如果播放器为空,则初始化播放器
            if (player == null) {
                player = new ExoPlayer.Builder(itemView.getContext()).build();
                playerView.setPlayer(player);
                initPlayerListener();
            }

            // 创建媒体项并设置到播放器
            MediaItem mediaItem = MediaItem.fromUri(Uri.parse(videoPath));
            player.setMediaItem(mediaItem);
            player.prepare();
        }

        public void playVideo() {
            // 记录日志
            Log.d(TAG, "playVideo called");
            // 尝试播放视频
            try {
                if (player != null) {
                    player.play();
                    mIsPlaying = true;
                }
            } catch (Exception e) {
                // 记录异常信息
                Log.e(TAG, "Exception in playVideo: " + e.getMessage());
            }
        }

        public void pauseVideo() {
            // 记录日志
            Log.d(TAG, "pauseVideo called");
            // 尝试暂停视频
            try {
                if (player != null) {
                    player.pause();
                    mIsPlaying = false;
                }
            } catch (Exception e) {
                // 记录异常信息
                Log.e(TAG, "Exception in pauseVideo: " + e.getMessage());
            }
        }

        void releasePlayer() {
            // 记录日志
            Log.d(TAG, "releasePlayer called");
            // 如果播放器不为空,则释放资源
            if (player != null) {
                player.stop();
                player.clearMediaItems();
                player.release();
                player = null;
            }
        }

        /**
         * 初始化播放器监听器。
         */
        private void initPlayerListener() {
            // 添加播放器监听器
            player.addListener(new Player.Listener() {
                @Override
                public void onIsPlayingChanged(boolean isPlaying) {
                    // 记录日志
                    Log.d(TAG, "onIsPlayingChanged called with isPlaying: " + isPlaying);
                    // 更新播放状态
                    mIsPlaying = isPlaying;
                }

                @Override
                public void onPlaybackStateChanged(int playbackState) {
                    // 记录日志
                    Log.d(TAG, "onPlaybackStateChanged called with playbackState: " + playbackState);
                    // 如果播放结束,则更新播放状态
                    if (playbackState == Player.STATE_ENDED) {
                        mIsPlaying = false;
                    }
                }
            });
        }

        void togglePlayPause() {
            // 记录日志
            Log.d(TAG, "togglePlayPause called");
            // 如果播放器为空,则直接返回
            if (player == null) return;
            // 根据当前播放状态切换播放/暂停
            if (mIsPlaying) {
                player.pause();
            } else {
                player.play();
            }
        }

        private void toggleControlsVisibility() {
            // 记录日志
            Log.d(TAG, "toggleControlsVisibility called");
            // 根据当前播放状态切换控制按钮的可见性
            if (exoPlayBtn.getVisibility() == View.VISIBLE) {
                if (mIsPlaying) {
                    exoPlayBtn.setVisibility(View.GONE);
                    exoPauseBtn.setVisibility(View.VISIBLE);
                } else {
                    exoPlayBtn.setVisibility(View.VISIBLE);
                    exoPauseBtn.setVisibility(View.GONE);
                }
            } else {
                if (mIsPlaying) {
                    exoPauseBtn.setVisibility(View.VISIBLE);
                } else {
                    exoPlayBtn.setVisibility(View.VISIBLE);
                }
            }
        }
    }
}

3. 工具类 Utils

java 复制代码
package com.wangyou.preview;

import android.content.Context;
import android.os.Environment;
import android.util.DisplayMetrics;
import android.webkit.MimeTypeMap;

import java.io.File;

public class Utils {
    //判断文件是否为图片
    public static boolean isImage(String filePath) {
        if (filePath == null || filePath.isEmpty()) {
            return false;
        }
        String name = new File(filePath).getName().toLowerCase();
        String[] IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff"};
        for (String ext : IMAGE_EXTENSIONS) {
            if (name.endsWith(ext)) {
                return true;
            }
        }
        return false;
    }

    //判断文件是否为视频
    public static boolean isVideo(String filePath) {
        if (filePath == null || filePath.isEmpty()) {
            return false;
        }
        String name = new File(filePath).getName().toLowerCase();
        String[] VIDEO_EXTENSIONS = {".mp4", ".avi", ".mkv", ".mov", ".flv", ".wmv", ".mpg", ".mpeg"};
        for (String ext : VIDEO_EXTENSIONS) {
            if (name.endsWith(ext)) {
                return true;
            }
        }
        return false;
    }


    //判断文件是否为音乐文件
    private boolean isMusic(String filePath) {
        if (filePath == null || filePath.isEmpty()) {
            return false;
        }
        String name = new File(filePath).getName().toLowerCase();
        String[] MUSIC_EXTENSIONS = {".mp3", ".wav", ".aac", ".flac", ".m4a", ".ogg"};
        for (String ext : MUSIC_EXTENSIONS) {
            if (name.endsWith(ext)) {
                return true;
            }
        }
        return false;
    }

    public static String getMimeType(String path) {
        String extension = MimeTypeMap.getFileExtensionFromUrl(path);
        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
    }

    public static int dpToPx(Context context, int dp) {
        return (int) (dp * context.getResources().getDisplayMetrics().density);
    }

    public static int getRealScreenWidthPx(Context context) {
        DisplayMetrics metrics = context.getResources().getDisplayMetrics();
        return metrics.widthPixels;
    }

    //检查打开是是否音视频,发送媒体暂停事件
    public static void checkMediaPause(String filePath) {
        if (filePath == null || filePath.trim().isEmpty()) {
            Log.i(TAG, "checkMediaPause: Invalid file path");
            return;
        }

        boolean isMediaFile = isMusic(filePath) || isVideo(filePath);
        Log.i(TAG, "checkMediaPause: isMediaFile = " + isMediaFile);

        if (isMediaFile) {
            try {
                Instrumentation inst = new Instrumentation();
                inst.sendKeyDownUpSync(KEYCODE_MEDIA_PAUSE);
            } catch (Exception e) {
                Log.e(TAG, "checkMediaPause: Failed to send media pause event, e = " + e);
            }
        }
    }
}

四、布局文件

1. activity_preview.xml

xml 复制代码
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/main_theme_bg"
    android:orientation="vertical">

    <!-- 顶部工具栏 -->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/preview_toolbar"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginStart="16dp"
        android:layout_marginEnd="20dp">

        <!-- 返回按钮 -->
        <ImageView
            android:id="@+id/iv_preview_back"
            android:layout_width="32dp"
            android:layout_height="32dp"
            android:src="@drawable/ic_back"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <!-- 标题 -->
        <TextView
            android:id="@+id/tv_preview_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:ellipsize="middle"
            android:maxLines="1"
            android:paddingEnd="80dp"
            android:textColor="#ffffff"
            android:textSize="18sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toRightOf="@id/iv_preview_back"
            app:layout_constraintTop_toTopOf="parent" />

        <!-- 更多按钮 -->
        <ImageView
            android:id="@+id/iv_preview_more"
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:src="@drawable/ic_more"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>

    <!-- 分页容器 -->
    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/preview_viewpager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/preview_toolbar"
        android:layout_marginBottom="20dp" />
</RelativeLayout>

2. 动画资源

res/anim/fade_anim_in.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromAlpha="0.0"
    android:toAlpha="1.0" />

res/anim/fade_anim_out.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromAlpha="1.0"
    android:toAlpha="0.0" />

五、AndroidManifest 配置

xml 复制代码
<manifest>
    <!-- 添加必要权限 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    <application>
        <!-- 预览Activity -->
        <activity android:name=".PreviewActivity"
            android:exported="true"
            android:launchMode="singleTask"
            android:theme="@style/Theme.AppCompat.Light.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:scheme="content" />
                <data android:scheme="file" />
                <data android:mimeType="audio/*" />
                <data android:mimeType="video/*" />
                <data android:mimeType="image/*" />
            </intent-filter>
        </activity>
    </application>
</manifest>

六、优化建议

  1. 图片加载优化

    结合 Glide 实现图片加载和缓存:

    gradle 复制代码
    implementation 'com.github.bumptech.glide:glide:4.12.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
  2. ExoPlayer 缓存优化

    使用 CacheDataSource 实现媒体缓存:

    java 复制代码
    Cache cache = new SimpleCache(cacheDir, new LeastRecentlyUsedCacheEvictor(100 * 1024 * 1024));
    DataSource.Factory cacheDataSourceFactory = new CacheDataSource.Factory()
        .setCache(cache)
        .setUpstreamDataSourceFactory(new DefaultDataSource.Factory(context));
  3. 内存管理

    onDestroy 中主动释放资源:

    java 复制代码
    @Override
    protected void onDestroy() {
        if (player != null) {
            player.release();
            player = null;
        }
        super.onDestroy();
    }

七、总结

通过结合 PhotoViewExoPlayer,我们可以低成本地实现高效的媒体文件预览功能。PhotoView 提供了优秀的图片交互体验,而 ExoPlayer 则满足了音视频播放的需求。尽管两者在性能和功能上还有待优化的地方,但它们仍然是目前最主流的选择之一。开发者可以通过以下方式进一步提升体验:

  1. 性能优化:使用图片压缩、媒体缓存等技术
  2. 交互增强:添加过渡动画和手势反馈
  3. 错误处理:完善媒体加载失败的重试机制
  4. 自适应布局:针对不同屏幕尺寸优化显示效果

希望本文能为开发者提供完整的实现参考和优化思路!

相关推荐
tracyZhang1 小时前
NativeAllocationRegistry----通过绑定Java对象辅助回收native对象内存的机制
android
vv啊vv1 小时前
使用android studio 开发app笔记
android·笔记·android studio
冯浩(grow up)2 小时前
使用vs code终端访问mysql报错解决
android·数据库·mysql
_一条咸鱼_5 小时前
Android Fresco 框架工具与测试模块源码深度剖析(五)
android
QING6185 小时前
Android Jetpack Security 使用入门指南
android·安全·android jetpack
顾林海5 小时前
Jetpack LiveData 使用与原理解析
android·android jetpack
七郎的小院5 小时前
性能优化ANR系列之-BroadCastReceiver ANR原理
android·性能优化·客户端
QING6185 小时前
Android Jetpack WorkManager 详解
android·kotlin·android jetpack
今阳6 小时前
鸿蒙开发笔记-14-应用上下文Context
android·华为·harmonyos
东莞梦幻科技7 小时前
体育直播系统趣猜功能开发技术实现方案
android