低成本实现媒体文件预览

在移动应用开发中,媒体文件的预览功能是不可或缺的一部分。无论是图片、视频还是音频文件,都需要一个高效且易用的解决方案来满足用户需求。本文将介绍两个常用的开源库: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. 自适应布局:针对不同屏幕尺寸优化显示效果

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

相关推荐
阿巴斯甜2 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker2 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95272 天前
Andorid Google 登录接入文档
android
黄林晴2 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android