简单的 Android 漫画书阅读器
这是一个安卓应用程序,我认为它包含的功能集略多于查看.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文件:ZipInputStream
和ZipFile
。ZipFile
提供对Zip文件的随机读访问。由于我们希望能够向前和向后移动,甚至跳转到漫画的特定页面,所以这是我们想要使用的类。(ZipInputStream
只允许以串行方式访问文件的内容,这不是我们想要的。)
使用ZipFile
类相当简单。存档中存储的每个文件都有相应的ZipEntry
。要从存档中提取文件,使用适当的ZipEntry
调用ZipFile.getInputStream()
将以InputStream
的形式返回文件。
有两种方法可以获取ZipEntry
。ZipFile.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()
,它执行从索引到entryName
到ZipEntry
的映射以及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.java
和BitmapViewController.java
。
BitmapView
负责显示页面图像,并响应用户的缩放、捏合和滚动手势,以显示所选图像的适当部分。
BitmapViewController
负责响应用户的滑动(fling)手势,以更改BitmapView
中当前选定的页面。这种职责划分的原因是,我将来可以轻松地重用BitmapView
。例如,如果我想制作一个相册浏览器(或网络漫画阅读器),所有需要做的就是编写一个新的BitmapViewController,它响应滑动手势获取正确的位图。
通过以下代码将BitmapView
和BitmapViewController
链接在一起。
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.AsyncTask
。AsyncTask
在谷歌的这篇文档中得到了很好的介绍,因此我将不再赘述。
返回所选漫画
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作为其视图,创建主菜单并响应用户选择菜单操作,并响应用户在横向和纵向之间切换屏幕。