第十八章:自定义AssetLocator

前文已经分析了 AssetLocator 的工作原理,本章将介绍如何实现自定义AssetLocator。

jME3 提供了很多不同类型的 AssetLoactor,诸如:

如果感兴趣,读者可以直接查阅它们的源代码,阅读源码是学习的最好方式。

压缩文件格式

在游戏发布时,开发者经常需要把美术资产打成加密、压缩资源包。每个工作室都可能有自己的加密算法,但通用的压缩算法只有几种:zip, rar, tar, gz, 7z ...

Java语言原生支持 zip 压缩算法,相关类位于 java.util.zip 包中,jME3提供的ZipLocator 即是使用 zip 算法实现的资源查找和解压。

本文将选择 7-zip 压缩格式作为自定义 AssetLocator 的目标,将开发一个 SevenZAssetLocator 类,用于从 7z 格式的压缩包中提取文件内容。

当开发完成后,应该能够通过下面的方式来使用它:

public static void main(String[] args) {
    AssetManager assetManager = JmeSystem.newAssetManager();
    assetManager.registerLocator("assets.7z", SevenZAssetLocator.class);

    AssetInfo info = assetManager.locateAsset(new AssetKey("assets/hello.txt"));
    System.out.println(info == null? "加载成功": "加载失败");
}

第一步:下载7ZipJBind

使用Java程序来解压 7z 文件,需要一些第三方类库。 7-zip Java Binding 是一个开源Java实现,通过调用底层的 C/C++ 代码,实现对 7z 文件的解压。

下载地址: https://sourceforge.net/projects/sevenzipjbind/files/

因为我用的是Windows系统,下载得到了 sevenzipjbinding-9.20-2.00beta-AllWindows.zip。解压后,在 lib 目录中找到了 sevenzipjbinding.jarsevenzipjbinding-AllWindows.jar,把这两个 jar 文件添加到项目的依赖中即可。

第二步:初始化7ZipJBind

根据 SevenZipJBind 官方网站的介绍,先使用下面的代码来初始化SevenZipJBind。

import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.SevenZipNativeInitializationException;

public class SevenZipJBindingInitCheck {
    public static void main(String[] args) {
        try {
            SevenZip.initSevenZipFromPlatformJAR();
            System.out.println("7-Zip-JBinding library was initialized");
        } catch (SevenZipNativeInitializationException e) {
            e.printStackTrace();
        }
    }
}

运行的结果如下:

7-Zip-JBinding library was initialized

如果输出不是这个结果,可能你需要检查自己下载的jar文件是否完整。

第三步:打开压缩文件

7-zip可以解压很多不同压缩格式的文件,其中就包括 zip 格式。下面尝试用它来打开我刚下载的 sevenzipjbinding-9.20-2.00beta-AllWindows.zip 文件。

SevenZipJBind 官网提供了很多代码,参考样例,编写测试代码如下:

package net.jmecn.assets;

import java.io.IOException;
import java.io.RandomAccessFile;

import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.PropID;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.SevenZipException;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;

public class ListItems {

    public static void main(String[] args) {
        String archiveFilename = "sevenzipjbinding-9.20-2.00beta-AllWindows.zip";

        RandomAccessFile randomAccessFile = null;
        IInArchive inArchive = null;
        try {
            // 打开压缩文件
            randomAccessFile = new RandomAccessFile(archiveFilename, "r");
            inArchive = SevenZip.openInArchive(null, // 自动检测压缩格式
                    new RandomAccessFileInStream(randomAccessFile));

            // 读取压缩文件中的文件数
            System.out.println("Count of items in archive: " + inArchive.getNumberOfItems());

            // 遍历目录,查询文件内容
            System.out.println("   Size   | Compr.Sz. | Filename");
            System.out.println("----------+-----------+---------");
            int itemCount = inArchive.getNumberOfItems();
            for (int i = 0; i < itemCount; i++) {
                System.out.println(String.format("%9s | %9s | %s", // 
                        inArchive.getProperty(i, PropID.SIZE),        // 原文件大小
                        inArchive.getProperty(i, PropID.PACKED_SIZE), // 压缩后大小 
                        inArchive.getProperty(i, PropID.PATH)));      // 文件路径
            }
        } catch (Exception e) {
            System.err.println("Error occurs: " + e);
        } finally {
            // 关闭压缩文件
            if (inArchive != null) {
                try {
                    inArchive.close();
                } catch (SevenZipException e) {
                    System.err.println("Error closing archive: " + e);
                }
            }
            if (randomAccessFile != null) {
                try {
                    randomAccessFile.close();
                } catch (IOException e) {
                    System.err.println("Error closing file: " + e);
                }
            }
        }
    }
}

运行结果:

Count of items in archive: 16
    Size   | Compr.Sz. | Filename
-----------+-----------+---------
         0 |         0 | sevenzipjbinding-9.20-2.00beta-AllWindows
       153 |       125 | sevenzipjbinding-9.20-2.00beta-AllWindows\AUTHORS
       141 |       129 | sevenzipjbinding-9.20-2.00beta-AllWindows\CMakeLists.txt
     26444 |      9085 | sevenzipjbinding-9.20-2.00beta-AllWindows\COPYING
      8940 |      3232 | sevenzipjbinding-9.20-2.00beta-AllWindows\ChangeLog
     26444 |      9085 | sevenzipjbinding-9.20-2.00beta-AllWindows\LGPL
      7441 |      2826 | sevenzipjbinding-9.20-2.00beta-AllWindows\README
      1918 |       786 | sevenzipjbinding-9.20-2.00beta-AllWindows\ReleaseNotes.txt
       984 |       590 | sevenzipjbinding-9.20-2.00beta-AllWindows\THANKS
     72665 |     66013 | sevenzipjbinding-9.20-2.00beta-AllWindows\cpp-src.zip
     91407 |     80833 | sevenzipjbinding-9.20-2.00beta-AllWindows\java-src.zip
    278020 |    264548 | sevenzipjbinding-9.20-2.00beta-AllWindows\javadoc.zip
         0 |         0 | sevenzipjbinding-9.20-2.00beta-AllWindows\lib
   2297195 |   2289323 | sevenzipjbinding-9.20-2.00beta-AllWindows\lib\sevenzipjbinding-AllWindows.jar
     71015 |     58970 | sevenzipjbinding-9.20-2.00beta-AllWindows\lib\sevenzipjbinding.jar
    468405 |    451108 | sevenzipjbinding-9.20-2.00beta-AllWindows\website.zip

根据上面的例子,可以得知下列信息:

  • SevenZip.openInArchive() 方法可以打开压缩文件。
  • IInArchive 表示压缩文件,使用完毕应该调用 close() 方法关闭它。
  • IInArchive#getNumberOfItems() 方法可以拿到文件数量。
  • IInArchive 是根据下标来访问每一个文件的。
  • IInArchive#getProperty(i, PropID.PATH) 方法可以访问下标为 i 的文件属性。
  • PropID 是一个枚举类型;PropID.SIZE 表示源文件大小;PropID.PATH 表示文件路径,可用于资源定位。

第四步:解压文件

上一步得到的信息,足够进行资源定位了,但是还无法拿到具体的文件数据,更别提 InputStream 了。通过查看IInArchive接口的源代码,发现了三个解压接口。

接口一:

public void extract(int[] indices, boolean testMode, IArchiveExtractCallback extractCallback)
        throws SevenZipException;

第一个参数是一个int[] 数组。根据注释来看,IInArchive支持批量解压缩,只要把被解压的文件下标以数组形式传递给 extract 接口即可。

第二个参数testMode是测试模型。当值为 true 时并不会真的解压数据,只是测试一下文件是否可用;当值为 false 时才会解压数据。

第三个参数 extractCallback 是一个回调接口。extract方法实际上是通过接口回调来获得解压后的数据的。通过实现回调接口,可以自己决定如何处理解压后的数据。

看来免不了要自己实现回调接口了。

接口二:

public ExtractOperationResult extractSlow(int index, ISequentialOutStream outStream) throws SevenZipException;

这个接口可以解压缩单个文件。第一个参数 index 表示文件的下标(从0开始),第二个参数是一个回调接口,输出的数据通过这个接口来处理。

模式跟前一个接口差不多,只是少了一个参数,并且只解压单个文件。但似乎刚好适合在 AssetLocator 中实现。

接口三:

public ExtractOperationResult extractSlow(int index, ISequentialOutStream outStream, String password)
        throws SevenZipException;

这个接口与前一个接口几乎一样,只是多了第三个参数 password。如果压缩包中的数据是被加密过的,就可以用 password 参数来解密。当然,前提是你知道解压密码。

IArchiveExtractCallback接口:

这个接口中一共有5个方法要实现。

有两个方法比较简单,它们是用于统计解压进度的。setTotal() 用于记录文件的总字节数, setCompleted 则用于记录当前已解压的字节数。如果不需要统计进度,就不用管这两个方法。

public void setTotal(long total) throws SevenZipException;
public void setCompleted(long complete) throws SevenZipException;

另外三个方法如下:

public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException;
public void prepareOperation(ExtractAskMode extractAskMode) throws SevenZipException;
public void setOperationResult(ExtractOperationResult extractOperationResult) throws SevenZipException;

根据注释来看,SevenZipJBind 的实际解压功能是由 C/C++ 代码实现的。解压算法在执行时,会调用 getStream() 方法来获得一个输出流,用来输出数据。

prepareOperation() 方法会在解压之前被调用,用来设置解压请求模式。 ExtractAskMode 是一个枚举类型,它的值包括 EXTRACT、TEST、SKIP、UNKNOWNASKMODE。除了第一个EXTRACT请求外,另外三个都表示并不实际解压。

setOperationResult() 会在解压结束后被调用,设置解压缩的结果。ExtractOperationResult 也是一个枚举类型,当它的值为 OK 是表示解压成功,其他值表示解压过程中发生了异常。

看来,不管是 extract 还是 extractSlow 方法,都需要实现 ISequentialOutStream 接口。

ISequentialOutStream接口:

这个接口中只定义了一个方法:

public int write(byte[] data) throws SevenZipException;

根据该接口的注释来看,这个接口将会被底层的 C/C++ 代码调用。如果需要解压缩的文件比较大,那么这个 write 方法会被调用多次,每次只写入一小段数据。

测试解压接口

在大致读完这些接口的源代码和注释之后,已经了解了这些接口的用法。但是还应该写一个测试程序,实际验证一下这些接口的工作机制。

验证思路很简单:用最简单的方式实现这些接口,直接打印方法的参数。然后用接口的实现类调用 extract() 方法,看看运行时会发生什么事情。为了验证write方法是否真的会被调用多次,应该选择解压一个体积较大的文件。

具体代码如下:

package net.jmecn.assets;
import java.io.IOException;
import java.io.RandomAccessFile;

import net.sf.sevenzipjbinding.ExtractAskMode;
import net.sf.sevenzipjbinding.ExtractOperationResult;
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.ISequentialOutStream;
import net.sf.sevenzipjbinding.PropID;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.SevenZipException;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;

/**
 * 测试解压接口
 * @author yanmaoyuan
 *
 */
public class TestExtract {

    public static void main(String[] args) throws IOException {
        TestExtract app = new TestExtract("sevenzipjbinding-9.20-2.00beta-AllWindows.zip");
        app.tryExtract("sevenzipjbinding-9.20-2.00beta-AllWindows\\website.zip");
    }

    private String archiveFilename;

    /**
     * 构造方法,初始化压缩文件路径
     * @param filename
     */
    public TestExtract(String filename) {
        this.archiveFilename = filename;
    }

    /**
     * 尝试解压某个文件
     * @param filepath
     * @throws IOException
     */
    public void tryExtract(String filepath) throws IOException{
        // 打开压缩文件
        RandomAccessFile randomAccessFile = new RandomAccessFile(archiveFilename, "r");
        IInArchive inArchive = SevenZip.openInArchive(null, // 自动选择解压格式
                new RandomAccessFileInStream(randomAccessFile));

        // 查询文件
        int count = inArchive.getNumberOfItems();
        for(int i=0; i<count; i++) {
            String path = (String)inArchive.getProperty(i, PropID.PATH);
            Boolean isFolder = (Boolean)inArchive.getProperty(i, PropID.IS_FOLDER);

            if (isFolder) {
                // 文件夹勿需解压,跳过
                continue;
            }
            if (path.equals(filepath)) {
                // 记录开始时间
                long start = System.currentTimeMillis();

                // 解压
                inArchive.extract(new int[]{i}, false, callback);

                // 显示解压用时
                long time = System.currentTimeMillis() - start;
                System.out.println("time: " + time);

                // 终止循环
                break;
            }
        }
        // 关闭压缩文件
        inArchive.close();
    }
    // 实现 ISequentialOutStream 接口,打印方法参数。
    ISequentialOutStream out = new ISequentialOutStream() {
        @Override
        public int write(byte[] data) throws SevenZipException {
            System.out.println("write:" + data.length);
            return data.length;
        }
    };
    // 实现 IArchiveExtractCallback 接口,打印方法参数。
    IArchiveExtractCallback callback = new IArchiveExtractCallback() {
        @Override
        public void setTotal(long total) throws SevenZipException {
            System.out.println("total:" + total);
        }
        @Override
        public void setCompleted(long complete) throws SevenZipException {
            System.out.println("complete:" + complete);
        }
        @Override
        public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
            System.out.println("getStream:" + index + ", " + extractAskMode);
            return out;
        }
        @Override
        public void prepareOperation(ExtractAskMode extractAskMode) throws SevenZipException {
            System.out.println("prepare:" + extractAskMode);
        }
        @Override
        public void setOperationResult(ExtractOperationResult extractOperationResult) throws SevenZipException {
            System.out.println("result:" + extractOperationResult);
        }
    };

}

运行结果如下:

total:468405
complete:0
getStream:15, EXTRACT
prepare:EXTRACT
write:32768
write:32768
write:32768
write:32768
write:32768
write:32768
write:32768
write:32768
complete:262144
write:32768
write:32768
write:32768
write:32768
write:32768
write:32768
complete:468405
write:9653
result:OK
time: 12

根据这个运行结果,可以验证很多信息:

  • totalcomplete 用于统计解压进度,其中 total 可以拿到文件的原始大小;
  • 另外几个方法的调用顺序是 getStream > prepare > write > write > ... > write > result
  • completewrite 可能处于两个不同的线程中并行执行。
  • extract 方法是同步阻塞的。当数据解压完毕后,才会继续执行后面的代码,显示运行时间。

把 getStream() 方法中的代码稍微改一下,直接返回null,看看会发生什么。

    public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
        System.out.println("getStream:" + index + ", " + extractAskMode);
        // return out;
        return null;// <--------------
    }

运行结果如下:

total:468405
complete:0
getStream:15, EXTRACT
time: 2

可以看到,在没有提供 ISequentialOutStream 实例的情况下,preparewriteresult 等步骤根本就不会执行。

解压文件

既然已经了解 SevenZipJBind 的接口,那么就可以尝试实际解压一个文件了。在 ISequentialOutStream 的实现中,使用 FileOutputStream 来输出数据,把解压后的文件保存到磁盘上。

代码如下:

package net.jmecn.assets;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;

import net.sf.sevenzipjbinding.ExtractAskMode;
import net.sf.sevenzipjbinding.ExtractOperationResult;
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.ISequentialOutStream;
import net.sf.sevenzipjbinding.PropID;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.SevenZipException;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;

/**
 * 测试解压接口
 * @author yanmaoyuan
 *
 */
public class TestExtract {

    public static void main(String[] args) throws IOException {
        TestExtract app = new TestExtract("sevenzipjbinding-9.20-2.00beta-AllWindows.zip");
        app.tryExtract("sevenzipjbinding-9.20-2.00beta-AllWindows\\website.zip");
    }

    private String archiveFilename;
    // 文件输出流
    private FileOutputStream output;// <------------------

    /**
     * 构造方法,初始化压缩文件路径
     * @param filename
     */
    public TestExtract(String filename) {
        this.archiveFilename = filename;
    }

    /**
     * 尝试解压某个文件
     * @param filepath
     * @throws IOException
     */
    public void tryExtract(String filepath) throws IOException{
        // 打开压缩文件
        RandomAccessFile randomAccessFile = new RandomAccessFile(archiveFilename, "r");
        IInArchive inArchive = SevenZip.openInArchive(null, // 自动选择解压格式
                new RandomAccessFileInStream(randomAccessFile));

        // 查询文件
        int count = inArchive.getNumberOfItems();
        for(int i=0; i<count; i++) {
            String path = (String)inArchive.getProperty(i, PropID.PATH);
            Boolean isFolder = (Boolean)inArchive.getProperty(i, PropID.IS_FOLDER);

            if (isFolder) {
                // 文件夹勿需解压,跳过
                continue;
            }
            if (path.equals(filepath)) {

                // 记录开始时间
                long start = System.currentTimeMillis();

                // 检查文件夹是否存在,不存在则创建
                int n = filepath.lastIndexOf("\\");
                String folder = filepath.substring(0, n);
                File dir = new File(folder);
                if(!dir.exists())
                    dir.mkdirs();

                // 打开文件流
                File file = new File(filepath);
                output = new FileOutputStream(file);//<------------

                // 解压
                inArchive.extract(new int[]{i}, false, callback);

                // 关闭文件流
                output.flush();
                output.close();
                output = null;

                // 显示解压用时
                long time = System.currentTimeMillis() - start;
                System.out.println("time: " + time);

                // 终止循环
                break;
            }
        }


        // 关闭压缩文件
        inArchive.close();
    }
    // 实现 ISequentialOutStream 接口,打印方法参数。
    ISequentialOutStream out = new ISequentialOutStream() {
        @Override
        public int write(byte[] data) throws SevenZipException {
            try {
                if (output != null) {
                    // 写文件
                    output.write(data);//<----------
                }
            } catch (IOException e) {
                throw new SevenZipException(e);
            }

            return data.length;
        }
    };

    // 实现 IArchiveExtractCallback 接口,打印方法参数。
    IArchiveExtractCallback callback = new IArchiveExtractCallback() {
        @Override
        public void setTotal(long total) throws SevenZipException {
            System.out.println("total:" + total);
        }
        @Override
        public void setCompleted(long complete) throws SevenZipException {
            System.out.println("complete:" + complete);
        }
        @Override
        public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
            System.out.println("getStream:" + index + ", " + extractAskMode);
            return out;
        }
        @Override
        public void prepareOperation(ExtractAskMode extractAskMode) throws SevenZipException {
            System.out.println("prepare:" + extractAskMode);
        }
        @Override
        public void setOperationResult(ExtractOperationResult extractOperationResult) throws SevenZipException {
            System.out.println("result:" + extractOperationResult);
        }
    };

}

运行结果。

第五步:自定义AssetLocator

通过前四步,对SevenZipJBind如何查询、如何解压文件已经有所了解。下面定义一个 SevenZAssetLocator,使用SevenZipJBind 来定位压缩包中的文件。

实现AssetLocator

首先定义SevenZAssetLocator,实现 AssetLocator 接口。

package net.jmecn.assets;

import com.jme3.asset.AssetInfo;
import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetLocator;
import com.jme3.asset.AssetManager;

/**
 * 7z 资产定位器
 * @author yanmaoyuan
 *
 */
public class SevenZAssetLocator implements AssetLocator {

    @Override
    public void setRootPath(String rootPath) {
    }

    @Override
    public AssetInfo locate(AssetManager manager, AssetKey key) {
        return null;
    }
}

AssetLocator 接口中有两个方法:

  • public void setRootPath(String rootPath) 用于定义查找资产的根目录。对于 SevenZAssetLocator 来说,这个方法将传入压缩文件所在的路径。
  • public AssetInfo locate(AssetManager manager, AssetKey key) 用于查找资源,返回AssetInfo 对象。

根据前文的介绍,可以很容易地实现这两个方法。

package net.jmecn.assets;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.HashMap;

import com.jme3.asset.AssetInfo;
import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetLoadException;
import com.jme3.asset.AssetLocator;
import com.jme3.asset.AssetManager;

import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.PropID;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;

/**
 * 7z 资产定位器
 * @author yanmaoyuan
 *
 */
public class SevenZAssetLocator implements AssetLocator {

    // 压缩文件
    private IInArchive inArchive;
    // 文件路径缓存,用来加速查询时间。
    private HashMap<String, Integer> cache;

    @Override
    public void setRootPath(String rootPath) {
        try {
            // 以只读模式打开压缩包
            RandomAccessFile randomAccessFile = new RandomAccessFile(rootPath, "r");
            inArchive = SevenZip.openInArchive(null,// 自动选择解压格式
                    new RandomAccessFileInStream(randomAccessFile));

            // 统计文件数量,并缓存文件路径
            cache = new HashMap<String, Integer>();
            int count = inArchive.getNumberOfItems();
            for(int i=0; i<count; i++) {
                Boolean isFolder = (Boolean)inArchive.getProperty(i, PropID.IS_FOLDER);
                if (isFolder) {
                    continue;// 文件夹勿需解压,跳过
                }

                String path = (String)inArchive.getProperty(i, PropID.PATH);
                // 统一文件分隔符
                path = path.replaceAll("\\\\", "/");
                // 缓存文件路径
                cache.put(path, i);
            }
        } catch (IOException e) {
            throw new AssetLoadException("Failed to open archive file: " + rootPath, e);
        }
    }

    @Override
    public AssetInfo locate(AssetManager manager, AssetKey key) {
        // 获得文件路径
        String name = key.getName();

        // 统一文件分隔符
        name = name.replace("\\\\", "/");

        if(name.startsWith("/"))
            name = name.substring(1);


        // 查找文件索引
        Integer index = cache.get(name);
        System.out.println(index);
        return null;
    }
}

在上面的代码中,setRootPath 方法的主要作用是打开压缩包,并使用HashMap来缓存压缩包中的文件路径。这样在 locate 方法中就可以直接查询文件的索引。

实现AssetInfo

目前 locate 方法直接返回了 null。想要正常使用,必须返回一个AssetInfo对象才行。

用户不需要关心这个 AssetInfo 对象的具体实现,因此我直接在 SevenZAssetLocator 类中定义了一个私有内部类 SevenZipAssetInfo,并让它实现 AssetInfo 中的 openStream() 方法。

SevenZipAssetInfo 类还需要负责解压文件数据。当用户调用 openStream() 方法时,需要返回一个 InputStream 对象,把解压后的数据交给客户。为了实现文件解压,这个类还需要实现 ISequentialOutStreamIArchiveExtractCallback 这两个接口。

    @Override
    public AssetInfo locate(AssetManager manager, AssetKey key) {
        // 获得文件路径
        String name = key.getName();

        // 统一文件分隔符
        name = name.replace("\\\\", "/");

        if(name.startsWith("/"))
            name = name.substring(1);


        // 查找文件索引
        Integer index = cache.get(name);
        if (index != null) {
            return new SevenZipAssetInfo(manager, key, index);
        } else {
            return null;
        }
    }

    private class SevenZipAssetInfo extends AssetInfo implements ISequentialOutStream, IArchiveExtractCallback {
        private int index = -1;// 待解压的文件索引

        public SevenZipAssetInfo(AssetManager manager, AssetKey key, int index) {
            super(manager, key);
            this.index = index;
        }

        @Override
        public InputStream openStream() {
            return null;
        }

        @Override
        public int write(byte[] data) throws SevenZipException {
            return 0;
        }

        @Override
        public void setTotal(long total) throws SevenZipException {}

        @Override
        public void setCompleted(long complete) throws SevenZipException {}

        @Override
        public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
            return null;
        }
        @Override
        public void prepareOperation(ExtractAskMode extractAskMode) throws SevenZipException {}
        @Override
        public void setOperationResult(ExtractOperationResult result) throws SevenZipException {}
    }

前文已经实现了文件解压,做法是通过 FileOutputStream 把数据写到磁盘文件中。但现在需要的是一个 InputStream 对象,又应该怎么做呢?

一种比较简单的办法,是创建一个字节数组 byte[] data,把解压后的数据保存到内存中。然后再用 ByteArrayInputStream 来包装这个数组,提供给客户使用。按照同样的思路,还可以使用 Java nio 中的 ByteBuffer 来做到同样的事。

这么做的风险在于,如果被解压的文件较大,就可能发生内存溢出的错误。因为文件数据是直接缓存在Java虚拟机的堆内存中的。不过考虑到单个游戏资产文件一般不会太大,应该还是可以接受的。

完整的实现代码如下:

package net.jmecn.assets;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.util.HashMap;

import com.jme3.asset.AssetInfo;
import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetLoadException;
import com.jme3.asset.AssetLocator;
import com.jme3.asset.AssetManager;

import net.sf.sevenzipjbinding.ExtractAskMode;
import net.sf.sevenzipjbinding.ExtractOperationResult;
import net.sf.sevenzipjbinding.IArchiveExtractCallback;
import net.sf.sevenzipjbinding.IInArchive;
import net.sf.sevenzipjbinding.ISequentialOutStream;
import net.sf.sevenzipjbinding.PropID;
import net.sf.sevenzipjbinding.SevenZip;
import net.sf.sevenzipjbinding.SevenZipException;
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;

/**
 * 7z 资产定位器
 * @author yanmaoyuan
 *
 */
public class SevenZAssetLocator implements AssetLocator {

    // 压缩文件
    private IInArchive inArchive;
    // 文件路径缓存,用来加速查询时间。
    private HashMap<String, Integer> cache;

    @Override
    public void setRootPath(String rootPath) {
        try {
            // 以只读模式打开压缩包
            RandomAccessFile randomAccessFile = new RandomAccessFile(rootPath, "r");
            inArchive = SevenZip.openInArchive(null,// 自动选择解压格式
                    new RandomAccessFileInStream(randomAccessFile));

            // 统计文件数量,并缓存文件路径
            cache = new HashMap<String, Integer>();
            int count = inArchive.getNumberOfItems();
            for(int i=0; i<count; i++) {
                Boolean isFolder = (Boolean)inArchive.getProperty(i, PropID.IS_FOLDER);
                if (isFolder) {
                    continue;// 文件夹勿需解压,跳过
                }

                String path = (String)inArchive.getProperty(i, PropID.PATH);
                // 统一文件分隔符
                path = path.replaceAll("\\\\", "/");
                // 缓存文件路径
                cache.put(path, i);
            }
        } catch (IOException e) {
            throw new AssetLoadException("Failed to open archive file: " + rootPath, e);
        }
    }

    @Override
    public AssetInfo locate(AssetManager manager, AssetKey key) {
        // 获得文件路径
        String name = key.getName();

        // 统一文件分隔符
        name = name.replace("\\\\", "/");

        if(name.startsWith("/"))
            name = name.substring(1);


        // 查找文件索引
        Integer index = cache.get(name);
        if (index != null) {
            return new SevenZipAssetInfo(manager, key, index);
        } else {
            return null;
        }
    }

    private class SevenZipAssetInfo extends AssetInfo implements ISequentialOutStream, IArchiveExtractCallback {
        private ByteArrayOutputStream out;
        private ExtractOperationResult result;

        private int index = -1;// 待解压的文件索引

        public SevenZipAssetInfo(AssetManager manager, AssetKey key, int index) {
            super(manager, key);
            this.index = index;
        }

        @Override
        public InputStream openStream() {
            try {
                result = null;

                // 解压数据
                inArchive.extract(new int[]{index}, false, this);

                // 解压失败
                if (result != ExtractOperationResult.OK) {
                    throw new AssetLoadException("Extracting operation error: " + result);
                }

                // 解压成功,获得解压后的数据。
                byte[] data = out.toByteArray();
                out = null;

                // 返回一个内存字节输入流
                return new ByteArrayInputStream(data);
            } catch (IOException e) {
                throw new AssetLoadException("Failed to extract file: " + index, e);
            }
        }

        @Override
        public int write(byte[] data) throws SevenZipException {
            try {
                // 把数据写到内存字节流中。
                out.write(data);
            } catch (IOException e) {
                e.printStackTrace();
            }
            return data.length;
        }

        @Override
        public void setTotal(long total) throws SevenZipException {
            System.out.println(total);
            // 根据数据总长度,初始化内存字节输出流。
            // FIXME 若内存不足,可能发生内存溢出。
            out = new ByteArrayOutputStream((int)total);
        }

        @Override
        public void setCompleted(long complete) throws SevenZipException {}

        @Override
        public ISequentialOutStream getStream(int index, ExtractAskMode extractAskMode) throws SevenZipException {
            // 非解压模式下,不解压数据。
            if (extractAskMode != ExtractAskMode.EXTRACT)
                return null;

            return this;
        }
        @Override
        public void prepareOperation(ExtractAskMode extractAskMode) throws SevenZipException {}
        @Override
        public void setOperationResult(ExtractOperationResult result) throws SevenZipException {
            // 记录解压结果
            this.result = result;
        }
    }
}

测试程序

编写一个测试程序:

package net.jmecn.assets;

import java.io.IOException;
import java.io.InputStream;
import java.util.Scanner;

import com.jme3.asset.AssetInfo;
import com.jme3.asset.AssetKey;
import com.jme3.asset.AssetManager;
import com.jme3.system.JmeSystem;

/**
 * 测试SevenZAssetLocator
 * @author yanmaoyuan
 *
 */
public class TestSevenZ {

    public static void main(String[] args) {
        AssetManager assetManager = JmeSystem.newAssetManager();
        assetManager.registerLocator("sevenzipjbinding-9.20-2.00beta-AllWindows.zip", SevenZAssetLocator.class);

        AssetInfo info = assetManager.locateAsset(new AssetKey("sevenzipjbinding-9.20-2.00beta-AllWindows/AUTHORS"));
        System.out.println(info != null? "加载成功": "加载失败");

        if (info != null) {
            // 读取文件内容
            InputStream in = info.openStream();
            String line = null;

            // 逐行读取,并打印到控制台。
            Scanner scanner = new Scanner(in);
            while(scanner.hasNextLine()) {
                line = scanner.nextLine();
                System.out.println(line);
            }

            // 关闭输入流
            try {
                in.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

运行结果:

加载成功
153
Authors of SevenZipJBinding.
See also the files THANKS, ChangeLog and git history.

Boris Brodski has initially designed and implemented 7-Zip-JBinding.