Kotlin – Unzip Zip files in Android

코틀린으로 파일을 효율적으로 읽는 방법 에 이은 두번째 글이다. (나름대로의 코드 골프라고도 명할 수 있겠다.)

지금 개발하는 앱에서는

  • 서버에서 Zip 다운로드 주소를 줌
  • 앱에서 주소를 받아 다운로드 하고 특정 경로에 압축을 풀음
  • 기능 실행

이런 구조로 작동해야 되는 것이 많다.

압축 파일을 풀어서 파일 하나하나를 작성해야 하는 만큼, BufferedOutputStream 을 이용할 수 밖에 없는데, 코틀린으로 어떻게 쓸 수 있는지 알아보기 위해서 코드를 몇 개 정도 작성했다.

결과적으론 32줄 (원본) > 20줄 (1차) > 16줄 (2차) > 14줄 (3차) > 9줄 (4차) 정도로 줄어들었다.

0. 의존 라이브러리

ZipFile 등이 안드로이드 API 24 (7.0) 이상부터 사용할 수 있어서, Apache ant를 추가했다.

compile group: 'org.apache.ant', name: 'ant', version: '1.10.1'

1. 자바

public void unzip(String filePath, String targetPath) {
   try {
       ZipFile zipFile = new ZipFile(filePath, "euc-kr");
       Enumeration e = zipFile.getEntries();
       while (e.hasMoreElements()) {
           ZipEntry entry = (ZipEntry) e.nextElement();
           File destinationFilePath = new File(targetPath, entry.getName());

           destinationFilePath.getParentFile().mkdirs();

           if (entry.isDirectory()) 
               continue;
                
           BufferedInputStream bis = new BufferedInputStream(zipFile.getInputStream(entry));

           int b;
           byte buffer[] = new byte[1024];
           FileOutputStream fos = new FileOutputStream(destinationFilePath);
           BufferedOutputStream bos = new BufferedOutputStream(fos, 1024);

           while ((b = bis.read(buffer, 0, 1024)) != -1) {
               bos.write(buffer, 0, b);
           }

           bos.flush();
           bos.close();
           bis.close();
       }
   } catch (Exception e) {
       e.printStackTrace();
   }
}

32줄. 매우 길다.

파라미터로는 filePath는 압축을 풀을 zip 경로, targetPath는 압축을 풀 경로이다.

2. use 사용

우선적으론 코틀린으로 파일을 효율적으로 읽는 방법 에서도 활용한 .use 메소드를 적극 활용해서 줄여보려고 한다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    val enumeration = zip.entries
    while (enumeration.hasMoreElements()) {
        val entry = enumeration.nextElement()
        val destFilePath = File(targetPath, entry.name)
        destFilePath.parentFile.mkdirs()

        if (entry.isDirectory)
            continue

        val bufferedIs = BufferedInputStream(zip.getInputStream(entry))

        bufferedIs.use {
            destFilePath.outputStream().buffered(1024).use { bos ->
                bufferedIs.copyTo(bos)
            }
        }
    }
}

그 외 while 문 등 기본적인 구조는 유지했다.

File.outputStream()란 확장 메소드로 FileOutputStream 을 꺼내고, FileOutputStream.buffered(size) 로 BufferedOutputStream을 꺼낸 다음 최종적으로 use 메소드를 사용해서 바이트 어레이를 copyTo 하는 것이다.

3. forEach ?

Enumeration 이란 클래스는 객체들의 집합에서 각각의 객체를 하나에 하나씩 처리할 수 있도록 돕는 인터페이스로, 두 개의 메소드를 제공한다.

package java.util;

public interface Enumeration<E> {
    boolean hasMoreElements(); // 다음 객체가 있는지

    E nextElement(); // 다음 객체 리턴
}

얼핏 보면 iterator랑 비슷한것 같다.

그리고 코틀린에서는 이 클래스의 확장 메소드로 Enumeration<E>.toList() 를 제공하는데, 열거된 순서로 Enumeration을 가져와 List 형태로 만들어준다.

그러면 언제나와 같이, 확장 메소드를 하나 더 만들어서 기능을 wrap 해보자.

private inline fun <E> Enumeration<E>.forEach(action: (E) -> Unit) {
    for (element in this.toList()) action(element)
}

기본적으로 Enumeration.toList() 한 결과값을 for-each 하여 한 객체마다 action을 invoke 하는 코드이다.

성능 이슈를 줄이기 위하여 Inline Function 을 사용했고,  Invoke operator를 사용했다.

위 확장 메소드를 사용해 만든 코드가 이쪽이다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory) {
            val bufferedIs = BufferedInputStream(zip.getInputStream(it))
            bufferedIs.use {
                destFilePath.outputStream().buffered(1024).use { bos ->
                    bufferedIs.copyTo(bos)
                }
            }
        }
    }
}

forEach를 사용함으로서 implicit parameter (통칭 it) 가 사용이 가능해졌기 때문에,  val enumeration = zip.entries 와 val entry = enumeration.nextElement() 를 없애고 it를 사용했다.

4. 한번 더 줄여보자.

bufferedIs 를 굳이 선언하지 않아도 통합이 가능할 것 같다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                destFilePath.outputStream().buffered(1024).use { bos -> 
                    bis.copyTo(bos)
                }
            }
    }
}

위 3번의 enumeration 처럼 변수 선언을 없애고 bis 라는 변수를 내부에서 선언함으로서 bis.copyTo(bos) 형태가 되었다.

그런데 잠깐만, 마지막의 buffered(1024).use 도 역시 하나의 파라미터니까 it를 사용할 수 있지 않을까?

… 결과적으로 나온 코드가 이쪽이다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        val destFilePath = File(targetPath, it.name)
        destFilePath.parentFile.mkdirs()

        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                destFilePath.outputStream().buffered(1024).use { bis.copyTo(it) }
            }
    }
}

폴더가 항상 만들어져 있다는 가정을 하면, destFilePath.parentFile.mkdirs() 를 제거하고 통합할 수 있다.

다행히 다른 클래스에서 경로를 반환할 때 항시 mkdirs를 거치도록 작성해놨기 때문에, 과감하게 제거한다.

private fun unzip(zipFile: File, targetPath: String) {
    val zip = ZipFile(zipFile, "euc-kr")
    zip.entries.forEach {
        if (!it.isDirectory)
            BufferedInputStream(zip.getInputStream(it)).use { bis ->
                File(targetPath, it.name).outputStream().buffered(1024).use { bis.copyTo(it) }
            }
    }
}

5. (부가) Semantic highlighting

마지막 나온 코드는 이 it가 이 it인지, 저 it가 저 it인지 구별이 쉽지 않다.

Kotlin 1.1.3 부터는 Semantic highlighting 를 제공하는데, 선택산 색상대로 변수에 색상을 지정하는 것이다.

첫번째 it와 두번째 it는 각각 ZipEntry를, 세번째 it는 BufferedOutputStream 을 가리키는 것 같다.

마무리

ZipFile도 줄일 수 있으면 더욱 좋았겠지만, 저것 만큼은 어떻게든 줄일 방법이 떠오르지 않았다.

아무튼, 32줄에서 9줄이면 약 72%의 감축을 이뤄낸 셈이 되었다.