[Flutter] 밀리의 서재 뷰어 기능 구현

negu63·2022년 1월 25일

벨로그에서 다른 분들은 플러터로 어떤 재미난 일을 하고 계신지 궁금해서 둘러보던 중 LOCKED님이 밀리의 서재 뷰어에 대한 글을 쓰신 것을 보았다. Drawer에 대해 글을 쓰셨는데 그것을 보니 예전에 텍스트 뷰어를 만들다가 접었던게 생각이 났고 이번 기회에 다시 한 번 구현에 도전해보았고 이를 공유하고자 한다.

시행착오

처음에는 단순히 화면의 크기를 구하고 그 화면에 들어가는 글자 갯수를 구한 다음 전체 텍스트에서 그만큼 잘라서 배열에 담아두고 페이지 번호를 통해 읽어서 화면에 뿌리는 방법을 사용했다.

이 방법을 사용하기 위해서는 한 화면에 들어가는 글자 수를 높이를 이용해서 구하는 것이 관건이었다. 시도한 방법들은 다음 4가지이다.

1. RenderBox

final constraints = BoxConstraints(maxWidth: maxWidth);
RichText rt = RichText(
    text: content,
    strutStyle: StrutStyle(
        forceStrutHeight: true,
        height: fontSize * n,
    ),
    ...
);
final ro = rt.createRenderObject(context);
ro.layout(constraints);
// 높이 = ro.size.height;

주어진 문자열로 RichText 위젯을 만들고 렌더한 후 크기를 구하는 방법이다.

글자마다 높이가 달라서 strutStyle로 모든 글자의 높이를 맞춰주지 않으면 높은 글자가 중간에 있으면 뒷부분이 다음 페이지로 넘어가서 페이지 끝자락이 안 예쁘게 나온다.

수천 자의 글로 테스트할 때는 잘 동작했지만 대략 책 1권 정도인 20만자의 글로 테스트하면 굉~장히 오래 걸린다. 오래 걸려도 괜찮지 않을까 싶을 수 있지만 다트는 단일 스레드를 사용하기 때문에 20만 자에 대한 전체 페이지 높이를 계산하는 동안 화면이 멈춘다. 그래서 로딩도 구현이 불가능한듯 하다.

isolate를 이용해서 해결할 수 있을 것 같았지만 root isolate만이 ui에 접근할 수 있다고 나오며 오류가 났다.

UI actions are only available on root isolate. 

그리고 context가 필요해서 불-편
놓아주었다..

2. TextPainter

TextPainter textPainter = TextPainter(
    strutStyle: StrutStyle(
        forceStrutHeight: true,
        height: fontSize * n,
    ),
    ...
);
textPainter.text = TextSpan(
    text: content,
    ...
);
textPainter.layout(maxWidth: maxWidth);
// 높이 = textPainter.height;

방법도 성능도 1번과 거의 흡사하다.
좀 더 빠를 것 같았는데 실험해보니 별 차이가 없었다.
느리고 isolate로 해결할 수 없었다.
패쓰~

3. LineMatrics
Flutter Line Metrics
구글링하다가 발견한 아주 좋은 방법이었다. RenderBox와 TextPainter로 높이를 계산하는 방법을 엄청 빠르고 쉽게 할 수 있었다. 속도 개선이 많이 되어서 답을 찾은줄 알았는데 밀리에 비하면 현저히 느렸다.

4. 미리 계산하기

실시간으로 페이지별 텍스트를 변경하니 대기 시간 때문에 사용하기가 너무 별로인 것 같아서 미리 계산을 다 해놓고 불러오기만 하는 방법을 사용해보았다. 그런데 밀리는 폰트도 여러 개를 지원했고 폰트 크기도 여러 개를 지원했다. (폰트 개수 * 폰트 크기 수)의 횟수 만큼 연산해야 해서 시간이 꽤나 오래 걸렸고 또 사용자가 폰트를 추가할 수 있었는데 그렇게 되면 폰트를 추가한 만큼 연산 시간이 증가한다. pc기준 기본 폰트 7개에 크기는 110단계씩 있다.
최소 770번...? 이거도 아니다.
이때 플러터로 만든게 아닐 수 있다는 의구심이 들었다.

추리

직접 사용해보기 위해 밀리의 서재 계정을 생성했다. pc 버전과 모바일 버전을 직접 사용해보니 감이 왔다. pc 버전은 Electron이라는 것으로 만든 것 같았고 모바일 버전은 플러터로 만든 것 같았는데 성능이나 기능은 거의 동일 했다.

두 가지를 따로 만들지는 않았을 것 같아서 밀리에서 어떤 사람들을 뽑고 있나 봤는데 재밌는 것을 발견했다. 밀리의 서재 프론트엔드 개발자 채용 공고에 주요업무가 Epub 웹 뷰어 개발이라 적혀있는 것이었다. Epub 웹 뷰어를 만들고 플러터 웹뷰로 띄우면 된다!

Epub.js

Epub.js라는 Epub 웹 뷰어 라이브러리가 이미 있었다.
깃허브랑 문서를 보고 사용법을 익히고 페이지를 구성했다.
문서 내용이 너무 부실해서 깃허브 이슈 답변을 보고 많이 배웠다.

웹뷰 구현

방법을 찾고 나니 구현은 별 거 없었다.
그냥 플러터 assets에 html과 js 등을 넣고 웹뷰로 열면 된다.
webview_flutter를 사용했다.
주의할 점은 javascriptMode를 unrestircted로 설정해줘야 한다는 것이다.
기본값이 disabled라서 자바스크립트 실행이 안된다.

final Completer<WebViewController> _controller =
    Completer<WebViewController>();
...
body: WebView(
        initialUrl: 'about:blank',
        javascriptMode: JavascriptMode.unrestricted, // 꼮!!꼮!
        onWebViewCreated: (WebViewController controller) async {
          _controller.complete(controller);
          (await _controller.future)
              .loadFlutterAsset('assets/reader/index.html');
        },
    ),
...

웹뷰에 있는 웹의 자바스크립트를 제어하려면 이런 식으로 하면 된다.

(await _controller.future).runJavascript(자바스크립트 코드);

Completer 문법은 처음 써봤는데 신기했다.

결과

이상한 나라의 앨리스 영문판(약 52000자)로 테스트한 결과이다.
너무 잘 된다!

크기

자간

문장 간격

문단 간격

색상

폰트

다크 모드

끝?

문제점1. 한글

끝인줄 알고 정리하려 했는데 쎄한 느낌이 들어서 뭔가 놓친게 없나 생각해보니 영어로 된 Epub 파일 밖에 못 구해서 한글도 잘 되는지 테스트를 하지 않았던 것이다. 그래서 Epub 파일을 수정할 수 있는 프로그램을 받아서 한글을 넣고 테스트 해보니 폰트 변경이 안된다. 웹에서는 한글도 잘 됐는데 왜 이런지 잘 모르겠다. 별 짓을 다 해봐도 안된다.

문제점2. 페이지 수 계산

Epub.js가 페이지를 원래 책의 페이지로만 나타내서 글꼴을 바꿔서 새로 생성되는 페이지들의 페이지 번호를 알아낼 수가 없다. 계산하는 방법을 찾아야 할 듯 하다.

끝!

평소 같았으면 승부욕이 발동해서 계속 매달렸을테지만 지금 엄청난 생각이 떠올라서 그 생각을 실현해야 해서 일단 넘어가기로 했다. 그래도 예전에 못 만들었던 것을 만들어서 좋다. 재밌었따

profile
No matter how long it take

2개의 댓글

comment-user-thumbnail
2023년 12월 1일

제 경험상 대용량의 텍스트를 페이지 분할 할 때 분할된 텍스트의 배열을 직접 저장하는 것은 효율이 지나치게 떨어져서 분할할 index의 배열을 저장하는 쪽이 효과적이었습니다.
https://pub.dev/packages/text_pagination
이와 관련해서 라이브러리도 만든 적 있었는데 손놓고 있던 사이에 플러터 버전이 올라가서 호환이 안되네요

답글 달기
comment-user-thumbnail
2024년 7월 10일

혹시 깃 주소 공유 가능할까요? 저도 공부중인데 epub.js를 가지고 플러터에서 할려니 계속 막혀서요ㅠㅠ 가능하시면
이메일 : kjh5848@gmail.com 으로 부탁드립니다.

답글 달기