65.9K
CodeProject 正在变化。 阅读更多。
Home

简单的 Android 漫画书阅读器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (23投票s)

2012年12月11日

CPOL

11分钟阅读

viewsIcon

57001

downloadIcon

2287

这是一个安卓应用程序,我认为它包含的功能集略多于查看.cbz格式文件所需的最低限度。

引言

这是一个安卓应用程序,我认为它包含的功能集略多于查看.cbz格式文件所需的最低限度。

这些功能包括:

  • SD卡上.cbz文件的“列表视图”,如上图所示。对于每个文件:
    • 显示漫画第一页的缩略图。
    • 显示文件名。
    • 允许用户开始查看文件的其余部分。
  • 用于阅读.cbz文件的“查看器”。
    • 查看器一次显示一页(或部分页面)。
    • 使用滑动(fling)手势移动到下一页或上一页。
    • 双击可放大页面的一部分。
    • 可以使用捏合手势进行放大和缩小。
    • 放大时,可以通过拖动(drag)手势滚动图像。
  • 用户可以设置书签(漫画书和页码)。应用程序首次启动时,将转到书签处。
  • 一个菜单,允许用户设置书签、返回书签,或转到.cbz文件列表视图以选择不同的漫画进行查看。

就安卓功能而言,此代码演示了如何:

  • 枚举SD卡上的文件
  • 读取zip文件。
  • 从文件读取(并调整大小)位图
  • 显示具有缩放、滚动、捏合缩放和滑动功能的位图。
  • 处理用户在横向和纵向之间更改屏幕方向。
  • 使用Intent在安卓应用程序的活动之间传递数据。
  • 将用户设置保存到持久存储,并在以后检索它们。
  • 提供菜单。
  • 自定义ListActivity项目布局
  • 创建一个简单的对话框。
  • 使用AsyncTask在后台线程中执行工作

警告,这是我写的第二个安卓应用程序,也是我的第一篇Code Project文章,所以可能有很多地方可以改进。欢迎反馈。

使用代码

如果您不知道如何设置Eclipse和Android SDK,请点击此处查看说明。

下载项目,解压并导入Eclipse。最低要求Android 2.3。

漫画文件格式

实际上有多种存储漫画的格式。最简单(对我们来说最容易)的是.cbz。它是一组图像文件(通常是PNG或JPEG),这些文件已打包到zip存档文件中。每张图像都是漫画的一页。

本项目中的CbzComic.java负责解码.cbz文件的内容。从前面的.cbz描述中,可以将.cbz存档文件视为位图数组。因此,CbzComic类最重要的函数是“获取表示第N页的位图”和“获取位图数量”。这些分别由函数getPage()numPages()实现。

读取Zip存档文件的内容。

安卓提供了两个主要的类来读取ZIP文件:ZipInputStreamZipFileZipFile提供对Zip文件的随机读访问。由于我们希望能够向前和向后移动,甚至跳转到漫画的特定页面,所以这是我们想要使用的类。(ZipInputStream只允许以串行方式访问文件的内容,这不是我们想要的。)

使用ZipFile类相当简单。存档中存储的每个文件都有相应的ZipEntry。要从存档中提取文件,使用适当的ZipEntry调用ZipFile.getInputStream()将以InputStream的形式返回文件。

有两种方法可以获取ZipEntryZipFile.getEntry(String entryName)将返回具有特定名称的ZipEntry,但这需要您提前知道entryName。另一种方法是ZipFile.entries(),它返回一个枚举,为您提供所有条目。

由于我们希望以随机顺序访问zip存档中的文件,最简单的方法是使用ZipFile.entries()获取所有条目,并将它们放入一个数组中。然后,要获取代表漫画第“n”页的文件,我们只需获取数组中第“n”个元素中保存的ZipEntry,并使用它来获取InputStream。

然而,由于安卓通常用于内存受限的移动设备,因此CbzFile类没有将ZipEntry本身存储在数组中,而是将每个条目的名称存储在数组中。然后,当我们想要存档中的特定页面时,会使用该名称调用ZipFile.getEntry()来获取相应的ZipEntry,然后使用它来获取InputStream。

一旦我们有了InputStream,将其转换为位图就微不足道了。BitmapFactory类的decodeStream()函数为我们完成了这项工作。

因此,CbzComic中最有趣的函数是构造函数,它构建ZipEntry名称数组,以及getPage(),它执行从索引到entryNameZipEntry的映射以及InputStream到位图的转换。还有一个getPageAsThumbnail(),它展示了如何获取已缩小的页面位图。因此,例如,它可以作为菜单上的缩略图使用。

    public CbzComic(String fileName) {
        mFileName = fileName;
        try {
            // populate mPages with the names of all the ZipEntries
            mZip = new ZipFile(fileName);
            mPages = new ArrayList();
            Enumeration<? extends ZipEntry> entries = mZip.entries();
            while (entries.hasMoreElements()) {
                ZipEntry entry = entries.nextElement();
                if (isImageFile(entry)) {
                    mPages.add(entry.getName());
                }
            }
        } catch (IOException e) {
            Log.e(Globals.TAG, "Error opening file", e);
        }
    }

    public Bitmap getPage(int pageNum) {
        Bitmap bitmap = null;
        try {
            ZipEntry entry = mZip.getEntry(mPages.get(pageNum));
            InputStream in = null;
            try {
                in = mZip.getInputStream(entry);
                bitmap = BitmapFactory.decodeStream(in);
            }  finally {
                if (in != null) { 
                    in.close();
                }
            }
        } catch (IOException e) {
            Log.e(Globals.TAG, "Error loading bitmap", e);
        }
        return bitmap;
    }

查看漫画页面

漫画的查看在两个类之间进行:BitmapView.javaBitmapViewController.java

BitmapView负责显示页面图像,并响应用户的缩放、捏合和滚动手势,以显示所选图像的适当部分。

BitmapViewController负责响应用户的滑动(fling)手势,以更改BitmapView中当前选定的页面。这种职责划分的原因是,我将来可以轻松地重用BitmapView。例如,如果我想制作一个相册浏览器(或网络漫画阅读器),所有需要做的就是编写一个新的BitmapViewController,它响应滑动手势获取正确的位图。

通过以下代码将BitmapViewBitmapViewController链接在一起。

    mBitmapView = (BitmapView) findViewById(R.id.comicView);
    mBitmapController = new BitmapViewController(mBitmapView, (Activity) this);
    mBitmapView.setController(mBitmapController);

BitmapView实际上是一个非常简单的类。它派生自View,并重写onDraw()以向用户显示当前选定的图像(或其部分)。让View对滚动、滑动和缩放手势做出反应稍微复杂,因为View不会直接将这些手势作为事件接收。相反,它的onTouchEvent()会通过MotionEvents调用,您需要分析这些事件以确定用户正在做出的手势。但是,您可以使用android.view.GestureDetector为您完成此分析工作。这涉及三个步骤。

首先,创建一个派生自android.view.GestureDetector.SimpleOnGestureListener的匿名类。当GestureDetector确定发生手势时,此类具有一组将被调用的方法。例如,onDoubleTap()onScroll()等。对于您要处理的每个手势,您都将重写该函数并实现处理该手势的功能。

    private SimpleOnGestureListener mGestureListener = new SimpleOnGestureListener() {
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            ZoomInOnPoint(e);
            return true;
        }

        @Override
        public void onLongPress(MotionEvent e) {
            if (mController != null) {
                mController.onLongPress();
            }
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            scrollViewport(distanceX, distanceY);
            return true;
        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (mController != null) {
                mController.onFling(e1, e2, velocityX, velocityY);
            }
            return true;
        }
    };

接下来,您创建一个android.view.GestureDetector,并将其与SimpleOnGestureListener关联起来。

    public BitmapView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mGestureDetector = new GestureDetector(context, mGestureListener);
    }

最后,您重写View的onTouchEvent()并将MotionEvents传递给GestureDetector

    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);
        return true;
    }

一个小麻烦是GestureDetector不处理“捏合缩放”。为了实现这一点,除了GestureDetector之外,您还需要使用ScaleGestureDetector及其匹配的SimpleOnScaleGestureListener

如前所述,BitmapView不直接处理将滑动转换为“翻页”操作,这是由BitmapViewController完成的。然而,由于滑动是由GestureDetector检测到的,当它们发生时,BitmapView将它们传递给BitmapViewController。还有一个(又一个)小问题是,我们希望用户能够同时执行滚动和滑动手势,而GestureDetector有时会将小的滚动动作解释为滑动。或者在滚动动作结束时添加滑动。因此,为了避免用户只是滚动时翻页,我们检查滑动是否超过了长度和速度的阈值标准。请注意,这些阈值是通过实验确定的,可能不适合所有用户。理想情况下,我们将提供设置,以便每个用户都可以将阈值调整到最适合自己的值。

    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        int minDistance = mBitmapView.getWidth() / 4;
        float minVelocity = minDistance * 6;  
        float distanceX = Math.abs(e2.getX() - e1.getX()) / 2.0f;
        if ((minDistance < distanceX) && (minVelocity < Math.abs(velocityX))) {
            if (0 < velocityX) {
                backPage();
            } else {
                forwardPage();
            }
        }
        return false;
    }

除了设置GestureDetectors之外,BitmapView的大部分代码都在跟踪屏幕上应显示的位图区域,以及响应缩放和滚动请求调整区域的数学计算。

查看漫画文件列表

ListComicsActivity.java提供UI,允许用户选择要查看的漫画。即,它提供此UI。

因此,此类的任务有三项:

  • 查找可用的.cbz文件。
  • 以允许用户选择其中一个的方式向用户显示找到的文件
  • 将选择返回给主活动。

查找.cbz文件是一个投机取巧的做法。由于这是一个最小的查看器,它只列出SD卡“Downloads”目录中的所有文件。这实际上应该通过内容提供者来完成。(一个可能的未来功能。)获取文件列表的代码是isMediaAvailable()listComicFiles(),它们使用漫画文件列表加载mFileNames

    private boolean isMediaAvailable() {
        String state = Environment.getExternalStorageState();
        if (Environment.MEDIA_MOUNTED.equals(state)) {
            return true;
        } else {
            return Environment.MEDIA_MOUNTED_READ_ONLY.equals(state);
        }
    }

    private void listComicFiles() {
        if (!isMediaAvailable()) {
            Utility.showToast(this, R.string.sd_card_not_mounted);
        } else {
            File path = Environment
                    .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
            mRootPath = path.toString();
            mFileNames = new ArrayList();
            String[] filesInDirectory = path.list();
            if (filesInDirectory != null) {
                for (String fileName : filesInDirectory) {
                    mFileNames.add(fileName);
                }
            }
            if (mFileNames.isEmpty()) {
                Utility.showToast(this, R.string.no_comics_found);
            }
        }
    }

ListComicsActivity派生自ListActivity,并使用ListActivity提供UI。关于如何使用ListActivity的基础知识,请参阅这篇文章

此类的主要额外关注点是使用后台线程在菜单上填充缩略图,并将用户选择的漫画返回给MainActivity

使用后台线程加载缩略图,因为此操作可能需要很长时间,因此不应在UI线程上运行。这由LoadThumbnailsTask类实现,该类派生自android.os.AsyncTaskAsyncTask谷歌的这篇文档中得到了很好的介绍,因此我将不再赘述。

返回所选漫画

ListActivity是一个活动,我们希望它返回一个结果。因此,要使其出现,它是通过调用startActivityForResult()从主活动启动的。

    private void launchComicList() {
        Intent listComicsIntent = new Intent(this, ListComicsActivity.class);
        startActivityForResult(listComicsIntent, 0);
    }

要从ListComicsActivity返回信息,您需要创建一个Intent,将所需信息添加到Intent中,调用setResult(),然后调用finish()以结束ListComicsActivity并返回到启动ListComicsActivity的活动。

    OnClickListener readButtonListener = new OnClickListener() {
        @Override
        public void onClick(View v) {
            String fileName = titleToFileName((String) v.getTag());
            Intent intent = new Intent();
            intent.putExtra(FILENAME_EXTRA, fileName);
            setResult(RESULT_OK, intent);
            finish();
        }
    };

ListComicsActivity结束时,启动它的活动中的onActivityResult()会被调用,并带有来自setResult()的Intent。因此,我们重写onActivityResult()并从Intent中提取信息。

    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK) {
            String fileName = data.getStringExtra(ListComicsActivity.FILENAME_EXTRA);
            loadComic(fileName, 0);
        }
    }

书签

此应用程序的最后一个功能是设置和恢复书签的能力。最常见的情况是,在关闭应用程序之前,用户应该能够告诉应用程序记住当前显示的漫画和页面。稍后,当应用程序重新启动时,它应该返回到该漫画和页面。Bookmark.java负责保存/加载此持久信息。

请注意,如果需要,应用程序可以通过重写MainActivity.onPause()在关闭时自动记录当前位置。它还可以存储多个书签,每个漫画一个。但为了目前保持简单,书签是通过用户选择“设置书签”菜单项来设置的。

正如谷歌所详细介绍的,有几种存储持久信息的方法。最简单的是Shared Preferences。以下是Bookmark如何使用Shared Preferences保存和加载状态信息:

    public void saveToSharedPreferences(Context context) {
        if (!isEmpty()) {
            SharedPreferences settings = context.getSharedPreferences(PREFS_NAME,
                    Context.MODE_PRIVATE);
            SharedPreferences.Editor editor = settings.edit();
            editor.putString(PREFS_COMIC_NAME, mComicName);
            editor.putInt(PREFS_PAGE, mPage);
            editor.commit();
        }
    }

    public Bookmark(Context context) {
        SharedPreferences settings = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
        mComicName = settings.getString(PREFS_COMIC_NAME, "");
        mPage = settings.getInt(PREFS_PAGE, -1);
    }

除了持久存储状态之外,为了处理屏幕方向更改(即从横向到纵向,反之亦然),我们还需要能够将状态保存到Bundle中。这是因为,当设备旋转时,安卓期望您将状态保存到Bundle中。然后,安卓会重新启动您的应用程序,传入Bundle,您的应用程序使用该Bundle来恢复其状态。

更详细地说,当设备旋转时,会调用您活动中的onSaveInstanceState()。您需要重写此函数并将需要持久化的任何状态保存到提供的Bundle中。在我们的例子中,我们想要的状态信息是当前正在查看的漫画和页面。

调用onSaveInstanceState后,操作系统将更改方向并重新启动您的应用程序,调用onCreate(),并传入来自onSaveInstanceState()的Bundle。请注意,当您的应用程序启动时也会调用onCreate()。但是,当它启动时,Bundle为空。因此,onCreate()的标准实现应该检查Bundle是否为空。如果不为空,则应用程序应使用Bundle中的信息恢复其状态。

以下是Bookmark如何将状态信息保存和加载到Bundle中,请注意代码与用于Shared Preferences的代码几乎相同。(奇怪的是,SharedPreferences和Bundles没有关联。)

    public void save(Bundle outState) {
        if (!isEmpty()) {
            outState.putString(PREFS_COMIC_NAME, mComicName);
            outState.putInt(PREFS_PAGE, mPage);
        }
    }

    public Bookmark(Bundle savedInstanceState) {
        mComicName = savedInstanceState.getString(PREFS_COMIC_NAME);
        mPage = savedInstanceState.getInt(PREFS_PAGE);
    }

在我们的主活动中,代码是:

    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        mBitmapController.getBookmark().save(outState);
    }

    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mBitmapView = (BitmapView) findViewById(R.id.comicView);
        mBitmapController = new BitmapViewController(mBitmapView, (Activity) this);
        mBitmapView.setController(mBitmapController);

        if (savedInstanceState != null) {
            // screen orientation changed, reload
            loadComic(new Bookmark(savedInstanceState));
        } else {
            // app has just been started.
            // If a bookmark has been saved, go to it, else, ask user for comic
            // to view
            Bookmark bookmark = new Bookmark(this);
            if (bookmark.isEmpty()) {
                launchComicList();
            } else {
                loadComic(bookmark);
            }
        }
    }

主活动

MainActivity.java是应用程序的主活动。它是应用程序启动时第一个启动的活动。它使用BitmapView作为其视图,创建主菜单并响应用户选择菜单操作,并响应用户在横向和纵向之间切换屏幕。

© . All rights reserved.