承上啟下:重構 Markdown 筆記應用 MarkNote

WngShhng發表於2018-12-16

1、關於專案

MarkNote 是一款 Android 端的筆記應用,它支援非常多的 Markdown 基礎語法,還包括了 MathJax, Html 等各種特性。此外,你還可以從相機或者相簿中選擇圖象並將其新增到自己的筆記中。這很酷!因為你可以將自己的遊記或者其他圖片拍攝下來並將其作為自己筆記的一部分。這也是筆者開發這款軟體的目的——希望 MarkNote 能夠成為一款幫助使用者記錄自己生活的筆記應用。

下面是我自己製作的一張部分功能預覽圖。這裡僅僅列舉了其中的部分頁面,當然,你可以在酷安網或者 Google Play Store 上面獲取到這個應用程式,並進一步瞭解它的全部功能,也可以在 Github 上得到最新版的應用的全部原始碼。

預覽圖

專案相關的連結

  1. 酷安網下載連結:https://www.coolapk.com/apk/178276
  2. Google Play Store 下載:https://play.google.com/store/apps/details?id=me.shouheng.notepal
  3. Github 專案連結:https://github.com/Shouheng88/MarkNote

最後,之所以把這次重構稱為 “承上啟下” 的一個很重要的原因是:這次重構程式碼其實是為了後續功能的開發鋪路。在未來,我會為這個應用增加更多有趣的功能。如果你對該專案感興趣的話,可以 Star 或者 Fork 該專案,併為專案貢獻程式碼。我們歡迎任何的、即使很小的貢獻 :)

2、關於重構

在之前的版本中,MarkNote 在功能、介面和程式碼方面都存在一些不足,所以,前些日子我又專門抽了些時間對這些不足的地方進行了一些優化,時間大概從 11 月中旬直到 12 月中旬。這次重構也進行了大量的程式碼優化。經過這次重構,專案增加了大概 100 多次 commit. 下面我們列舉一下本次重構所涉及的部分,其實也是這段時間以來學習到的東西的一些總結。

2.1 專案結構優化

2.1.1 包結構優化

首先,在之前筆者已經對專案的整個結構做了一次調整,主要是將專案中各個模組的位置進行了調整。這部分內容主要是專案中的 Gradle 配置和專案檔案的路徑的修改。在 settings.gradle 裡面,我按照下面的方式指定了依賴的各個模組的路徑:

include ':app', ':commons', ':data', ':pinlockview', ':fingerprint'
project(':commons').projectDir = new File('../commons')
project(':data').projectDir = new File('../data')
project(':pinlockview').projectDir = new File('../pinlockview')
project(':fingerprint').projectDir = new File('../fingerprint')
複製程式碼

這種方式最大的好處就是,專案中的 app, commons, data 等模組的檔案路徑處於相同的層次中,即:

--MarkNote
     |----client
     |----commons
     |----data
     ....
複製程式碼

這個調整當然是為了元件化開發做準備啦,當然這樣的結構相比於將各個模組全部放置在 client 下面清晰得多。

其次,我將專案中已經比較成熟的部分打包成了 aar,並直接引用該包,而不是繼續將其作為一個依賴的形式。這樣又進一步簡化了專案的結構。

最後是專案中的功能模組的拆分。在之前的專案中,Markdown 編輯器和解析、渲染相關的程式碼都被我放置在專案所引用的一個模組中。而這次,我直接將這個部分拆成了一個單獨的專案並將其開源到了 Github.

EasyMark

這麼做的主要目的是:

  1. 將核心的功能模組從專案中獨立出來單獨開發,以實現更多的功能並提升該部分的效能;
  2. 開源,希望能夠幫助想實現一個 Markdown 筆記的開發者快速整合這個功能;
  3. 開源,希望能夠有開發者參與進行以提升這部分的功能。

關於 Markdown 處理的部分被開源到了 Github,其地址是:github.com/Shouheng88/… ,該專案中同時還包含了一個非常好用的編輯器選單控制元件,感興趣的同學可以關注一下這個專案。

2.1.2 MVVM 調整

在該專案中,我們一直使用的是最新的 MVVM 設計模式,只是可惜的是在之前的版本中,筆者對 MVVM 的理解不夠深入,所以導致程式的結構更像是 MVP. 本次,我們對這個部分做了優化,使其更符合 MVVM 設計原則。

以筆記列表介面為例,當我們獲取了對應於 Fragment 的 ViewModel 之後,我們統一在 addSubscriptions() 方法中對其通知進行訂閱:

    viewModel.getMutableLiveData().observe(this, resources -> {
        assert resources != null;
        switch (resources.status) {
            case SUCCESS:
                adapter.setNewData(resources.data);
                getBinding().ivEmpty.showEmptyIcon();
                break;
            case LOADING:
                getBinding().ivEmpty.showProgressBar();
                break;
            case FAILED:
                ToastUtils.makeToast(R.string.text_failed);
                getBinding().ivEmpty.showEmptyIcon();
                break;
        }
    });
複製程式碼

這裡返回的 resources,是封裝的 Resource 的例項,是用來向觀察者傳遞程式執行結果的包裝類。然後,我們會使用 ViewModel 的 fetchMultiItems() 方法來根據之前傳入的頁面的狀態資訊拉取筆記記錄:

public Disposable fetchMultiItems() {
    if (mutableLiveData != null) {
        mutableLiveData.setValue(Resource.loading(null));
    }
    return Observable.create((ObservableOnSubscribe<List<NotesAdapter.MultiItem>>) emitter -> {
        List<NotesAdapter.MultiItem> multiItems = new LinkedList<>();
        List list;
        if (category != null) {
            switch (status) {
                case ARCHIVED: list = ArchiveHelper.getNotebooksAndNotes(category);break;
                case TRASHED: list = TrashHelper.getNotebooksAndNotes(category);break;
                default: list = NotebookHelper.getNotesAndNotebooks(category);
            }
        } else {
            switch (status) {
                case ARCHIVED: list = ArchiveHelper.getNotebooksAndNotes(notebook);break;
                case TRASHED: list = TrashHelper.getNotebooksAndNotes(notebook);break;
                default: list = NotebookHelper.getNotesAndNotebooks(notebook);
            }
        }
        for (Object obj : list) {
            if (obj instanceof Note) {
                multiItems.add(new NotesAdapter.MultiItem((Note) obj));
            } else if (obj instanceof Notebook) {
                multiItems.add(new NotesAdapter.MultiItem((Notebook) obj));
            }
        }
        emitter.onNext(multiItems);
    }).observeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(multiItems -> {
        if (mutableLiveData != null) {
            mutableLiveData.setValue(Resource.success(multiItems));
        }
    });
}
複製程式碼

從上面也可以看出,我們將從資料庫中獲取到資料的許多邏輯放在了 ViewModel 中,並且每當想要拉取資料的時候呼叫一下 fetchMultiItems() 方法即可。這樣,我們可以大大地減少 View 層的程式碼量。 View 層的邏輯也因此變得清晰得多。

2.2 介面優化:更純粹的質感設計

記得在 Material Design 剛推出的時候,筆者和許多其他開發者一樣興奮。不過,在實際的開發過程中我卻總是感覺不得要領,總覺少了一些什麼。不過,經過前段時間的學習,我對在應用中實現質感設計有了更多的認識。

2.2.1 Toolbar 的陰影效果

在之前的版本中,為了實現工具欄下面的陰影效果,我使用了在 Toolbar 下面增加一個高度為 5dp 的控制元件併為設定一個漸變背景的實現方式。這種實現方式可以完美相容 Android 系統的各個版本。但是,這種實現的效果沒有系統自帶的顯得那麼自然。在新的版本中,我使用了下面的方式來實現陰影的效果:

<android.support.design.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".activity.SearchActivity">

    <me.shouheng.commons.widget.theme.SupportAppBarLayout
        android:id="@+id/bar_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?attr/colorPrimary">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"/>

    </me.shouheng.commons.widget.theme.SupportAppBarLayout>

...
複製程式碼

這裡的 SupportAppBarLayout 繼承自支援包的 AppBarLayout,主要用來實現日夜間主題的相容。這樣 Toolbar 下面就會帶有一個漂亮的陰影,但是在比較低版本的手機上面是沒有效果的,所以,為了相容低版本的手機還要使用之前的那種使用控制元件填充的方式。(在新版本中暫時沒有做這個處理)

2.2.2 日夜間主題相容

在之前的專案中,支援 20 多種主題顏色和強調色,不過最近隨著 Google 在自己的專案中逐漸採用純白色的設計,我也拋棄了之前的邏輯。現在整個專案中只支援三種主題:

  1. 白色的主題 + 藍色的強調色
  2. 白色的主題 + 粉紅的強調色
  3. 黑色的主題 + 藍色的強調色

主題

對於主題的支援,我依然延續了之前的實現方式——通過重建 Activity 來實現主題的切換。同時,為了達到某些控制元件隨著主題自適應調整的目的,我定義了一些自定義控制元件,並在其中根據當前的設定選擇使用的顏色。而對於其他可以直接使用專案中的強調色或者主題色的部分,我們可以直接使用當前的主題的值,比如下面的 Toolbar 的背景顏色會使用當前主題中的 主題色

<android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="?attr/colorPrimary"/>
複製程式碼

2.2.3 啟動頁優化

之前的版本中在第一次開啟程式的時候會有一個啟動頁來展示程式的功能,新版本中直接移除了這個功能。取而代之的是使用啟動頁來進行優化,首秀定義一個主題。這個主題只應用於第一次開啟的 Activity。

<style name="AppTheme.Branded" parent="LightThemeBlue">
    <item name="colorPrimaryDark">#00a0e9</item>
    <item name="android:windowBackground">@drawable/branded_background</item>
</style>
複製程式碼

這裡,我們將介面的背景更換成我們自己的專案的圖示,因為專案圖示中使用的顏色與狀態列的顏色不一致,所以,這裡又重寫了 colorPrimaryDark 屬性以將狀態列的顏色和啟動頁的顏色設定成相同的效果:

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <color android:color="#00a0e9"/>
    </item>
    <item>
        <bitmap
            android:src="@drawable/mn"
            android:tileMode="disabled"
            android:gravity="center"/>
    </item>
</layer-list>
複製程式碼

這種實現方式的效果是,在程式開啟的時候不會存在白屏。之前的白屏會被我們指定的啟動頁替換掉(因為這個啟動頁是該 Activity 的視窗的背景)。當然,當頁面開啟完畢之後你還要在程式中將啟動頁背景替換掉。這樣優化之後程式開啟的時候顯得更加自然、流暢。

2.2.4 動畫優化

因為時間的原因,在當前的版本中,我並沒有加入太多的動畫,而只是對程式中的一些地方增加了動畫的效果。

在筆記的列表中,我使用了下面的動畫效果。這樣當開啟列表介面的時候各個條目會存在自底向上的進入動畫。

private int lastPosition = -1;

@Override
protected void convert(BaseViewHolder helper, MultiItem item) {
    // ... 
    /* Animations */
    if (PalmUtils.isLollipop()) {
        setAnimation(helper.itemView, helper.getAdapterPosition());
    } else {
        if (helper.getAdapterPosition() > 10) {
            setAnimation(helper.itemView, helper.getAdapterPosition());
        }
    }
}

private void setAnimation(View viewToAnimate, int position) {
    if (position > lastPosition) {
        Animation animation = AnimationUtils.loadAnimation(mContext, R.anim.anim_slide_in_bottom);
        viewToAnimate.startAnimation(animation);
        lastPosition = position;
    }
}
複製程式碼

不過,這種方式實現的並不是最理想的效果,因為當開啟頁面的時候,多條記錄會以一個整體的形式進入到頁面中。這也是以後的一個優化的地方。

2.3 使用 RxJava 重構

在之前的專案中,當進行非同步的操作的時候,需要定義一個 AsyncTask. 這種實現方式存在一個明顯的問題,當需要執行的非同步任務比較多,又無法進行復用的時候,你需要定義大量的 AsyncTask。另外,在各個頁面之間進行資料傳遞的時候,如果單純地使用 onActivityResult() 或者進行介面回撥(Fragment 和 Activity 之間)會使得程式碼繁瑣、難以閱讀。針對這些問題,我們可以使用 RxJava 來進行很好的優化。

首先是非同步操作的問題,我們可以使用 RxJava 來實現執行緒的切換。以下面的這段程式碼為例,它被用來實現儲存快速筆記的結果到檔案系統和資料庫中。在這段程式碼中,我們使用了 RxJava 的 create() 方法,並在其中進行邏輯的處理,然後使用 subscribeOn() 方法指定處理的執行緒是 IO 執行緒,並使用 observeOn() 方法指定最終處理的結果在主執行緒中進行處理:

public Disposable saveQuickNote(@NonNull Note note, QuickNote quickNote, @Nullable Attachment attachment) {
    return Observable.create((ObservableOnSubscribe<Note>) emitter -> {
        /* Prepare note content. */
        String content = quickNote.getContent();
        if (attachment != null) {
            attachment.setModelCode(note.getCode());
            attachment.setModelType(ModelType.NOTE);
            AttachmentsStore.getInstance().saveModel(attachment);
            if (Constants.MIME_TYPE_IMAGE.equalsIgnoreCase(attachment.getMineType())
                    || Constants.MIME_TYPE_SKETCH.equalsIgnoreCase(attachment.getMineType())) {
                content = content + "![](" + quickNote.getPicture() + ")";
            } else {
                content = content + "[](" + quickNote.getPicture() + ")";
            }
        }
        note.setContent(content);
        note.setTitle(NoteManager.getTitle(quickNote.getContent(), quickNote.getContent()));
        note.setPreviewImage(quickNote.getPicture());
        note.setPreviewContent(NoteManager.getPreview(note.getContent()));

        /* Save note to the file system. */
        String extension = UserPreferences.getInstance().getNoteFileExtension();
        File noteFile = FileManager.createNewAttachmentFile(PalmApp.getContext(), extension);
        try {
            Attachment atFile = ModelFactory.getAttachment();
            FileUtils.writeStringToFile(noteFile, note.getContent(), Constants.NOTE_FILE_ENCODING);
            atFile.setUri(FileManager.getUriFromFile(PalmApp.getContext(), noteFile));
            atFile.setSize(FileUtils.sizeOf(noteFile));
            atFile.setPath(noteFile.getPath());
            atFile.setName(noteFile.getName());
            atFile.setModelType(ModelType.NOTE);
            atFile.setModelCode(note.getCode());
            AttachmentsStore.getInstance().saveModel(atFile);
            note.setContentCode(atFile.getCode());
        } catch (IOException e) {
            emitter.onError(e);
        }

        /* Save note. */
        NotesStore.getInstance().saveModel(note);

        emitter.onNext(note);
    }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(note1 -> {
        if (saveNoteLiveData != null) {
            saveNoteLiveData.setValue(Resource.success(note1));
        }
    });
}
複製程式碼

另外是介面之間的結果傳遞的問題。對於 onActivityResult() 的執行結果,我們使用自定義的 RxBus 來傳遞資訊,它的作用類似於 EventBus。然後,我們為此而封裝了一個 RxMessage 物件來包裝返回的結果。但是在程式中,我們儘量來簡化和減少這種程式碼,因為過多的全域性訊息會讓程式碼除錯變得更加困難。我們希望程式碼邏輯更加簡單、清晰。

RxJava 除了能夠完成執行緒切換的任務之外,對程式碼的可讀性的提升效果也是非常明顯的。另外,它還非常適用於區域性的優化,比如,我們可以很輕易地改變自己的程式碼來將某個耗時邏輯放在非同步執行緒中執行來提升介面的響應速度。

2.4 增加新功能

2.4.1 桌面快捷方式

桌面快捷方式並不是所有的 Android 桌面都支援的,我們在程式中有兩個地方使用它。如下圖所示,第一種方式是在筆記內部點選建立快捷方式的時候在桌面建立應用的快捷方式,我們可以通過點選快捷方式來快速開啟筆記;第二種方式是長按應用圖示的時候彈出一個選單選項。

快捷方式

首先,第一種實現方式是在 7.0 之後加入的,之前我們也是可以建立快捷方式的,只是實現的方式與現在的方式不同而已。如下面這段程式碼所示,當 7.0 之後,我們使用 ShortcutManager 來建立快捷方式。之前,我們可以使用 "com.android.launcher.action.INSTALL_SHORTCUT" 這個 ACTION 並指定引數來建立快捷方式:

public static void createShortcut(Context context, @NonNull Note note) {
    Context mContext = context.getApplicationContext();
    Intent shortcutIntent = new Intent(mContext, MainActivity.class);
    shortcutIntent.putExtra(SHORTCUT_EXTRA_NOTE_CODE, note.getCode());
    shortcutIntent.setAction(SHORTCUT_ACTION_VIEW_NOTE);

    if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) {
        ShortcutManager mShortcutManager = context.getSystemService(ShortcutManager.class);
        if (mShortcutManager != null && VERSION.SDK_INT >= VERSION_CODES.O) {
            if (mShortcutManager.isRequestPinShortcutSupported()) {
                ShortcutInfo pinShortcutInfo = new Builder(context, String.valueOf(note.getCode()))
                        .setShortLabel(note.getTitle())
                        .setLongLabel(note.getTitle())
                        .setIntent(shortcutIntent)
                        .setIcon(Icon.createWithResource(context, R.drawable.ic_launcher_round))
                        .build();

                Intent pinnedShortcutCallbackIntent = mShortcutManager.createShortcutResultIntent(pinShortcutInfo);

                PendingIntent successCallback = PendingIntent.getBroadcast(context, /* request code */ 0,
                        pinnedShortcutCallbackIntent, /* flags */ 0);

                mShortcutManager.requestPinShortcut(pinShortcutInfo, successCallback.getIntentSender());
            }
        } else {
            createShortcutOld(context, shortcutIntent, note);
        }
    } else {
        createShortcutOld(context, shortcutIntent, note);
    }
}

private static void createShortcutOld(Context context, Intent shortcutIntent, Note note) {
    Intent addIntent = new Intent();
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, note.getTitle());
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE,
            Intent.ShortcutIconResource.fromContext(context, R.drawable.ic_launcher_round));
    addIntent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
    context.sendBroadcast(addIntent);
}
複製程式碼

對於第二種實現方式,我們可以在 Manifest 檔案中進行註冊,併為其指定 ACTION 和啟動類來實現各個選項被點選之後傳送的事件。然後,我們在指定的 Activity 中對各個 ACTION 進行處理即可,具體可以參考原始碼。另外,這裡的快速建立筆記還是比較有意思的,可以開啟一個背景透明的 Activity 並在其中彈出一個自定義對話方塊來快速編輯筆記。可以幫助我們快速地記錄自己的筆記。

2.4.2 指紋解鎖

當然,這部分功能,我們直接使用了一個開源的三方庫。畢竟人家為還為各個系統的指紋解鎖的支援做了處理,所以這裡我們直接奉行拿來主義了。這個專案的地址是:github.com/uccmawei/Fi….

2.4.3 開啟網頁的各種問題

開啟網頁當然不難實現,我們使用一個自定義的 WebView 即可實現。不過,在這個專案的重構版本中,我們採用了一個開源的庫 AgentWeb,它可以滿足我們非常多場景的應用。

另外,因為在我們的新的重構版本中,將支援包和 targetApi 都提升到了 28,所以出現了一個問題:使用 http 的網頁無法開啟。為了解決這個問題,我們需要在 Manifest 檔案中指定網路配置檔案的地址:

android:networkSecurityConfig="@xml/network_security_config"
複製程式碼

然後,在該配置檔案中指定我們可以訪問的 http 白名單:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">mikecrm.com</domain>
        <domain includeSubdomains="true">m.weibo.cn</domain>
    </domain-config>
</network-security-config>
複製程式碼

在這裡我們還發現了一個其他的問題:我們開啟網頁的時候設定的 Weibo 的連結是 https 的,但是因為我們在移動裝置上面使用,所以又被重定向到了 http://m.weibo.cn,導致我們的網頁無法開啟。解決的方式即按照上面那樣,將重定向之後的地址新增到白名單之中即可。

2.4.4 其他

  1. 在新的版本中,為了幫助我們進一步優化程式,我們使用了友盟進行埋點。
  2. 不註冊支付寶和微信支付賬號進行打賞;
  3. 分享相關的邏輯等;
  4. 其他:新版本中我們還增加了許多其他的邏輯,如果你感興趣的話可以檢視下程式碼。

3、總結

上面我們介紹了專案的一些內容和新版本重構時加入的新功能等。這些新加入的東西也算是這段時間以來學習成果的一個小集合。當然,因為畢竟業餘時間有限,程式碼中可能仍然存在一些不足和設計不良的地方,如果你發現了這些不愉快的問題,可以在 Github 上面為專案提 issue,很樂意與你溝通和學習!

最後,重申一下專案相關的連結:

  1. 酷安網下載連結:https://www.coolapk.com/apk/178276
  2. Google Play Store 下載:https://play.google.com/store/apps/details?id=me.shouheng.notepal
  3. Github 專案連結:https://github.com/Shouheng88/MarkNote

如果您喜歡我的文章,可以在以下平臺關注我:

更多文章:Gihub: Android-notes

相關文章