Panduan untuk arsitektur aplikasi

Panduan ini ditujukan bagi developer yang telah memahami dasar-dasar pembuatan aplikasi dan ingin mengetahui praktik terbaik dan arsitektur yang direkomendasikan untuk membuat aplikasi yang tangguh dan berkualitas produksi.

Halaman ini mengasumsikan Anda sudah memahami Framework Android. Jika Anda baru mengenal pengembangan aplikasi Android, lihat Panduan developer, yang membahas topik prasyarat untuk panduan ini.

Pengalaman pengguna aplikasi seluler

Dalam kebanyakan kasus, aplikasi desktop memiliki satu titik entri dari peluncur desktop atau program, kemudian bekerja sebagai proses monolitik tunggal. Di sisi lain, aplikasi Android memiliki struktur yang jauh lebih rumit. Aplikasi Android umumnya berisi beberapa komponen aplikasi, termasuk aktivitas, fragmen, layanan, penyedia layanan, dan penerima siaran.

Anda mendeklarasikan sebagian besar komponen aplikasi ini di manifes aplikasi Anda. OS Android kemudian menggunakan file ini untuk menentukan cara mengintegrasikan aplikasi Anda ke pengalaman pengguna secara keseluruhan di perangkat. Mengingat bahwa aplikasi Android yang dibuat dengan tepat berisi beberapa komponen, dan bahwa pengguna sering berinteraksi dengan beberapa aplikasi dalam rentang waktu yang singkat, aplikasi perlu beradaptasi dengan berbagai jenis alur kerja dan tugas yang dikelola pengguna.

Sebagai contoh, pertimbangkan apa yang terjadi saat Anda membagikan foto di aplikasi jaringan sosial favorit Anda:

  1. Aplikasi tersebut memicu intent kamera. OS Android kemudian meluncurkan aplikasi kamera untuk menangani permintaan itu.

    Pada tahap ini, pengguna telah keluar dari aplikasi jaringan sosial, tetapi pengalaman penggunaan aplikasi mereka tetap berjalan mulus.

  2. Aplikasi kamera dapat memicu intent lain, seperti meluncurkan pemilih file, yang dapat meluncurkan aplikasi lain lagi.

  3. Akhirnya, pengguna kembali ke aplikasi jaringan sosial dan membagikan fotonya.

Kapan saja selama proses ini, pengguna dapat diganggu oleh panggilan telepon atau notifikasi. Setelah merespons gangguan ini, pengguna berharap dapat kembali ke aplikasi dan melanjutkan proses berbagi foto ini. Perilaku melompat antar-aplikasi ini sangatlah umum di perangkat seluler, sehingga Anda perlu menangani alur-alur ini dengan tepat.

Ingatlah bahwa ada keterbatasan resource pada perangkat seluler, jadi kapan saja, sistem operasi mungkin perlu menghentikan beberapa aplikasi agar aplikasi lain dapat berjalan.

Mengingat kondisi lingkungan ini, kemungkinan aplikasi Anda dapat diluncurkan secara individual dan tidak berurutan, dan sistem operasi atau pengguna dapat merusak proses ini kapan saja. Karena peristiwa ini tidak berada di bawah kendali Anda, sebaiknya Anda tidak menyimpan data atau status aplikasi di komponen aplikasi, dan komponen aplikasi Anda sebaiknya tidak bergantung satu sama lain.

Prinsip arsitektur umum

Jika Anda tidak disarankan menggunakan komponen aplikasi untuk menyimpan data dan status aplikasi, bagaimana caranya Anda mendesain aplikasi?

Pemisahan fokus

Prinsip paling penting yang perlu diikuti adalah pemisahan fokus. Kesalahan umum yang biasa dilakukan adalah menulis semua kode dalam suatu Activity atau Fragment. Kelas berbasis UI ini hanya boleh memuat logika yang menangani UI dan interaksi sistem operasi. Dengan menjaga kelas-kelas ini tetap seramping mungkin, Anda dapat menghindari banyak masalah yang terkait dengan siklus proses.

Perlu diingat bahwa Anda tidak memiliki implementasi Activity dan Fragment; sebaliknya, ini hanyalah kelas penempel yang merepresentasikan kontrak antara OS Android dan aplikasi Anda. OS dapat memusnahkan kelas-kelas ini kapan saja berdasarkan interaksi pengguna atau karena kondisi sistem seperti memori yang rendah. Untuk memberikan pengalaman pengguna yang memuaskan dan pengalaman pemeliharaan aplikasi yang lebih mudah dikelola, sebaiknya minimalkan dependensi Anda terhadap kelas-kelas tersebut.

Menjalankan UI dari model

Prinsip penting lainnya adalah sebaiknya Anda menjalankan UI dari suatu model, terutama model persisten. Model adalah komponen yang bertanggung jawab menangani data untuk aplikasi. Model tidak terikat dengan objek View dan komponen aplikasi dalam aplikasi Anda, sehingga tidak terpengaruh oleh siklus proses aplikasi dan masalah terkait.

Persistensi ideal karena alasan berikut:

  • Pengguna Anda tidak kehilangan data jika OS Android memusnahkan aplikasi untuk mengosongkan resource.
  • Aplikasi Anda terus berfungsi saat sambungan jaringan tidak stabil atau tidak tersedia.

Dengan mendasarkan aplikasi Anda pada kelas model yang memiliki tanggung jawab jelas mengenai pengelolaan data, aplikasi Anda lebih dapat diuji dan konsisten.

Pada bagian ini, kami menunjukkan cara membuat struktur aplikasi menggunakan Komponen Arsitektur melalui kasus penggunaan menyeluruh.

Bayangkan kita sedang membuat UI yang menampilkan profil pengguna. Kita menggunakan backend pribadi dan REST API untuk mengambil data untuk profil yang diberikan.

Ringkasan

Untuk memulai, perhatikan diagram berikut, yang menunjukkan bagaimana semua modul harus berinteraksi satu sama lain setelah mendesain aplikasi:

Perhatikan bahwa setiap komponen hanya bergantung pada komponen yang berada satu level di bawahnya. Misalnya, aktivitas dan fragmen hanya bergantung pada model tampilan. Repositori adalah satu-satunya kelas yang bergantung pada beberapa kelas lainnya; dalam contoh ini, repositori bergantung pada model data persisten dan sumber data backend jarak jauh.

Desain ini menciptakan pengalaman pengguna yang konsisten dan menyenangkan. Terlepas dari apakah pengguna kembali ke aplikasi beberapa menit setelah mereka terakhir kali menutupnya, atau beberapa hari kemudian, mereka akan langsung melihat informasi pengguna bahwa aplikasi dipertahankan secara lokal. Jika data ini usang, modul repositori aplikasi akan mulai memperbarui data di latar belakang.

Membuat antarmuka pengguna

UI terdiri dari fragmen, UserProfileFragment, dan file tata letak yang sesuai, user_profile_layout.xml.

Untuk menjalankan UI, model data harus menyimpan elemen data berikut:

  • ID Pengguna: Pengidentifikasi untuk pengguna. Sebaiknya teruskan informasi ini ke fragmen menggunakan argumen fragmen. Jika OS Android memusnahkan proses, informasi ini tetap dipertahankan, sehingga ID ini tersedia saat aplikasi dimulai ulang.
  • Objek pengguna: Kelas data yang menyimpan detail tentang pengguna.

Kami menggunakan UserProfileViewModel, berdasarkan komponen arsitektur ViewModel, untuk menyimpan informasi ini.

Objek ViewModel menyediakan data untuk komponen UI tertentu, seperti fragmen atau aktivitas, dan berisi logika bisnis penanganan data untuk berkomunikasi dengan model. Misalnya, ViewModel dapat memanggil komponen lain untuk memuat data dan meneruskan permintaan pengguna untuk mengubah data tersebut. ViewModel tidak mengetahui komponen UI, sehingga tidak terpengaruh oleh perubahan konfigurasi, seperti pembuatan ulang aktivitas saat merotasi layar.

Sekarang kita telah mendefinisikan file-file berikut:

  • user_profile.xml: Definisi tata letak UI untuk layar.
  • UserProfileFragment: Pengontrol UI yang menampilkan data.
  • UserProfileViewModel: Kelas yang menyiapkan data untuk ditampilkan di UserProfileFragment dan bereaksi terhadap interaksi pengguna.

Cuplikan kode berikut menampilkan konten awal untuk file-file ini. (File tata letak dihilangkan agar lebih praktis.)

UserProfileViewModel

public class UserProfileViewModel extends ViewModel {
    private String userId;
    private User user;

    public void init(String userId) {
        this.userId = userId;
    }
    public User getUser() {
        return user;
    }
}

UserProfileFragment

public class UserProfileFragment extends Fragment {
    private static final String UID_KEY = "uid";
    private UserProfileViewModel viewModel;

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        String userId = getArguments().getString(UID_KEY);
        viewModel = ViewModelProviders.of(this).get(UserProfileViewModel.class);
        viewModel.init(userId);
    }

    @Override
    public View onCreateView(LayoutInflater inflater,
                @Nullable ViewGroup container,
                @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.user_profile, container, false);
    }
}

Setelah kita memiliki modul-modul kode ini, bagaimana cara menghubungkannya? Setelah kolom user ditetapkan di kelas UserProfileViewModel, kita memerlukan cara untuk memberi tahu UI. Di sinilah komponen arsitektur LiveData berfungsi.

LiveData adalah penyimpan data yang observable (dapat diamati). Komponen lain dalam aplikasi Anda dapat memantau perubahan pada objek menggunakan pengendali > tanpa membuat jalur dependensi yang eksplisit dan kaku antar-objek. Komponen LiveData juga mengikuti status siklus proses komponen aplikasi Anda—seperti aktivitas, fragmen, dan layanan—dan mencakup logika pembersihan untuk mencegah kebocoran objek dan konsumsi memori yang berlebihan.

Untuk menyertakan komponen LiveData ke dalam aplikasi, kami mengubah jenis kolom di UserProfileViewModel menjadi LiveData<User>. Sekarang, UserProfileFragment diberi tahu saat data diperbarui. Selanjutnya, karena kolom LiveData ini berbasis siklus proses, kolom ini otomatis membersihkan referensi setelah tidak lagi digunakan.

UserProfileViewModel

public class UserProfileViewModel extends ViewModel {
    ...
    private User user;
    private LiveData<User> user;
    public LiveData<User> getUser() {
        return user;
    }
}

Sekarang kita akan memodifikasi UserProfileFragment untuk mengamati data dan mengupdate UI:

UserProfileFragment

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    viewModel.getUser().observe(this, user -> {
      // Update UI.
    });
}

Setiap kali data profil pengguna diperbarui, callback onChanged() dipanggil, dan UI direfresh.

Jika Anda sudah terbiasa dengan library lain yang menggunakan callback observable, Anda mungkin menyadari bahwa kita tidak harus mengganti metode onStop() fragmen untuk berhenti mengamati data. Langkah ini tidak diperlukan dengan LiveData karena sudah berbasis siklus proses, yang berarti LiveData tidak akan memanggil callback onChanged() kecuali fragmen berstatus aktif; artinya, fragmen telah menerima onStart() tetapi belum menerima onStop()). LiveData juga otomatis menghapus pengamat saat metode onDestroy() fragmen dipanggil.

Kita juga tidak menambahkan logika apa pun untuk menangani perubahan konfigurasi, seperti pengguna yang merotasi layar perangkat. UserProfileViewModel otomatis dipulihkan saat konfigurasi berubah, jadi segera setelah fragmen baru dibuat, fragmen akan menerima instance ViewModel yang sama, dan callback segera dipanggil menggunakan data terbaru. Mengingat objek ViewModel diharapkan aktif lebih lama daripada objek View terkait yang diperbaruinya, sebaiknya Anda tidak menyertakan referensi langsung ke objek View dalam implementasi Anda ViewModel. Untuk informasi selengkapnya tentang masa aktif ViewModel yang terkait dengan siklus proses komponen UI, lihat Siklus proses ViewModel.

Mengambil data

Sekarang setelah kita menggunakan LiveData untuk menghubungkan UserProfileViewModel ke UserProfileFragment, bagaimana cara mengambil data profil pengguna?

Dalam contoh ini, kami berasumsi bahwa backend menyediakan REST API. Kami menggunakan library Retrofit untuk mengakses backend, tetapi Anda dapat menggunakan library yang berbeda yang fungsinya sama.

Berikut adalah definisi Webservice yang berkomunikasi dengan backend:

Webservice

public interface Webservice {
    /**
     * @GET declares an HTTP GET request
     * @Path("user") annotation on the userId parameter marks it as a
     * replacement for the {user} placeholder in the @GET path
     */
    @GET("/users/{user}")
    Call<User> getUser(@Path("user") String userId);
}

Ide pertama untuk mengimplementasikan ViewModel mungkin melibatkan pemanggilan langsung Webservice dengan tujuan mengambil data dan menetapkan data ini ke objek LiveData. Desain ini bisa digunakan, namun dengan menggunakannya, aplikasi akan semakin sulit dipelihara seiring perkembangannya. Desain ini memberikan tanggung jawab yang terlalu banyak pada kelas UserProfileViewModel, dan ini melanggar prinsip pemisahan fokus. Selain itu, cakupan ViewModel terikat dengan siklus proses Activity atau Fragment, yang berarti data dari Webservice akan hilang saat siklus proses objek UI terkait berakhir. Perilaku ini menciptakan pengalaman pengguna yang tidak diinginkan.

Sebaliknya, ViewModel kami mendelegasikan proses pengambilan data ke modul baru, repositori.

Modul repositori menangani operasi data. Modul ini menyajikan API yang bersih sehingga seluruh aplikasi dapat mengambil data ini dengan mudah. Repositori mengetahui asal data dan panggilan API mana yang harus digunakan saat data diperbarui. Anda dapat menganggap repositori sebagai mediator antara sumber data yang berbeda-beda, seperti model persisten, layanan web, dan cache.

Kelas UserRepository, ditampilkan dalam cuplikan kode berikut, menggunakan instance WebService untuk mengambil data pengguna:

UserRepository

public class UserRepository {
    private Webservice webservice;
    // ...
    public LiveData<User> getUser(int userId) {
        // This isn't an optimal implementation. We'll fix it later.
        final MutableLiveData<User> data = new MutableLiveData<>();
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }

            // Error case is left out for brevity.
        });
        return data;
    }
}

Meskipun tampaknya tidak diperlukan, modul repositori memiliki fungsi penting: memisahkan sumber data dari seluruh aplikasi. Sekarang, UserProfileViewModel tidak mengetahui bagaimana data diambil, sehingga kita dapat memberikan data yang diperoleh dari implementasi pengambilan data yang berlainan ke model tampilan.

Mengelola dependensi antarkomponen

Kelas UserRepository di atas memerlukan instance Webservice untuk mengambil data pengguna. Kelas ini dapat langsung membuat instance, tapi untuk melakukannya, ia juga perlu mengetahui dependensi kelas Webservice. Selain itu, UserRepository mungkin bukan satu-satunya kelas yang membutuhkan Webservice. Situasi ini mengharuskan kita menduplikasi kode, karena setiap kelas yang membutuhkan referensi ke Webservice perlu mengetahui cara menyusunnya dan dependensinya. Jika setiap kelas membuat WebService baru, aplikasi bisa menjadi resource yang sangat berat.

Anda dapat menggunakan pola desain berikut untuk mengatasi masalah ini:

  • Injeksi dependensi (DI): Injeksi dependensi memungkinkan kelas untuk menentukan dependensi tanpa perlu menyusunnya. Saat waktu proses, kelas lain bertanggung jawab menyediakan dependensi ini. Kami merekomendasikan library Dagger 2 untuk mengimplementasikan injeksi dependensi pada aplikasi Android. Dagger 2 otomatis menyusun objek dengan cara menelusuri hierarki dependensi, dan menyediakan jaminan waktu kompilasi pada dependensi.
  • Pencari lokasi: Pola pencari lokasi menyediakan registry di mana kelas dapat memperoleh, dan bukan menyusun, dependensinya.

Lebih mudah untuk mengimplementasikan registry layanan daripada menggunakan DI, jadi jika Anda tidak terbiasa dengan DI, gunakan pola pencari lokasi saja.

Kedua pola ini memungkinkan Anda menskala kode karena keduanya memberikan pola yang jelas untuk mengelola dependensi tanpa menduplikasi kode atau menambahkan kerumitan. Selain itu, pola tersebut memungkinkan Anda beralih dengan cepat antara pengujian dan implementasi pengambilan data produksi.

Aplikasi contoh kami menggunakan Dagger 2 untuk mengelola dependensi objek Webservice.

Menghubungkan ViewModel dan repositori

Sekarang, kita akan memodifikasi UserProfileViewModel untuk menggunakan objek UserRepository:

UserProfileViewModel

public class UserProfileViewModel extends ViewModel {
    private LiveData<User> user;
    private UserRepository userRepo;

    // Instructs Dagger 2 to provide the UserRepository parameter.
    @Inject
    public UserProfileViewModel(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    public void init(int userId) {
        if (this.user != null) {
            // ViewModel is created on a per-Fragment basis, so the userId
            // doesn't change.
            return;
        }
        user = userRepo.getUser(userId);
    }

    public LiveData<User> getUser() {
        return this.user;
    }
}

Meng-cache data

Implementasi UserRepository memisahkan panggilan ke objek Webservice, tetapi karena hanya mengandalkan satu sumber data, implementasi tersebut tidak terlalu fleksibel.

Masalah inti pada implementasi UserRepository adalah setelah data diambil dari backend, data tersebut tidak disimpan di mana pun. Oleh karena itu, jika pengguna keluar dari UserProfileFragment, kemudian kembali, aplikasi harus mengambil kembali data, meskipun data tidak berubah.

Desain ini kurang optimal karena alasan berikut:

  • Desain ini menyia-nyiakan bandwidth jaringan yang berharga.
  • Desain ini memaksa pengguna untuk menunggu hingga kueri baru diselesaikan.

Untuk mengatasi masalah ini, kami menambahkan sumber data baru ke UserRepository, yang berfungsi meng-cache objek User dalam memori:

UserRepository

// Informs Dagger that this class should be constructed only once.
@Singleton
public class UserRepository {
    private Webservice webservice;

    // Simple in-memory cache. Details omitted for brevity.
    private UserCache userCache;

    public LiveData<User> getUser(int userId) {
        LiveData<User> cached = userCache.get(userId);
        if (cached != null) {
            return cached;
        }

        final MutableLiveData<User> data = new MutableLiveData<>();
        userCache.put(userId, data);

        // This implementation is still suboptimal but better than before.
        // A complete implementation also handles error cases.
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }
        });
        return data;
    }
}

Mempertahankan data

Dengan implementasi kita saat ini, jika pengguna merotasi perangkat atau keluar dan segera kembali lagi ke aplikasi, UI yang ada langsung terlihat karena repositori mengambil data dari cache dalam memori.

Namun, bagaimana jika pengguna keluar dari aplikasi, dan baru kembali beberapa jam kemudian setelah OS Android memusnahkan proses? Jika dalam kasus tersebut kita mengandalkan implementasi saat ini, kita perlu mengambil kembali data dari jaringan. Proses pengambilan data kembali ini tidak hanya mengakibatkan pengalaman pengguna yang buruk, tetapi juga memboroskan kuota internet yang berharga.

Anda dapat memperbaiki masalah ini dengan meng-cache permintaan web, tetapi hal itu akan menciptakan masalah baru: Bagaimana jika data pengguna yang sama muncul dari jenis permintaan lain, seperti mengambil daftar teman? Aplikasi akan menampilkan data yang tidak konsisten, yang sangat membingungkan. Misalnya, aplikasi mungkin menampilkan dua versi berbeda dari data pengguna yang sama jika pengguna membuat permintaan daftar teman dan permintaan pengguna tunggal pada waktu yang berbeda. Aplikasi kita perlu mencari tahu cara menggabungkan data yang tidak konsisten ini.

Cara yang tepat untuk menangani situasi seperti ini adalah menggunakan model persisten. Dalam hal inilah library persistensi Room dapat membantu.

Room adalah library pemetaan objek yang menyediakan persistensi data lokal dengan kode boilerplate yang minimal. Saat mengompilasi, Room memvalidasi setiap kueri terhadap skema data Anda, sehingga kueri SQL yang rusak akan menghasilkan error waktu kompilasi, bukan kegagalan waktu proses. Room memisahkan beberapa detail implementasi pokok tentang menangani tabel dan kueri SQL mentah. Hal ini juga memungkinkan Anda untuk mengamati perubahan pada data database, termasuk kueri koleksi dan penggabung, dan memperlihatkan perubahan tersebut menggunakan objek LiveData. Room bahkan secara eksplisit menetapkan batasan eksekusi yang mengatasi masalah threading umum, seperti mengakses penyimpanan pada thread utama.

Untuk menggunakan Room, kita perlu mendefinisikan skema lokal. Pertama-tama, kita harus menambahkan anotasi @Entity ke kelas model data User dan anotasi @PrimaryKey ke kolom id kelas. Anotasi ini menandai User sebagai tabel dalam database dan id sebagai kunci utama tabel:

User

@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;

  // Getters and setters for fields.
}

Selanjutnya, kita membuat kelas database dengan mengimplementasikan RoomDatabase untuk aplikasi kita:

UserDatabase

@Database(entities = {User.class}, version = 1)
public abstract class UserDatabase extends RoomDatabase {
}

Perhatikan bahwa UserDatabase bersifat abstrak. Room secara otomatis menyediakan penerapannya. Untuk penjelasan selengkapnya, lihat dokumentasi Room.

Sekarang kita memerlukan cara untuk memasukkan data pengguna ke dalam database. Untuk tugas ini, kita akan membuat objek akses data (DAO).

UserDao

@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(int userId);
}

Perhatikan bahwa metode load menampilkan objek berjenis LiveData<User>. Room tahu saat database diubah dan otomatis memberi tahu semua pengamat aktif saat data berubah. Karena Room menggunakan LiveData, operasi ini efisien; Room mengupdate data hanya saat ada setidaknya satu pengamat aktif.

Setelah menetapkan kelas UserDao, selanjutnya kita akan mereferensikan DAO dari kelas database kita:

UserDatabase

@Database(entities = {User.class}, version = 1)
public abstract class UserDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

Sekarang kita dapat memodifikasi UserRepository untuk menerapkan sumber data Room:

@Singleton
public class UserRepository {
    private final Webservice webservice;
    private final UserDao userDao;
    private final Executor executor;

    @Inject
    public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
        this.webservice = webservice;
        this.userDao = userDao;
        this.executor = executor;
    }

    public LiveData<User> getUser(String userId) {
        refreshUser(userId);
        // Returns a LiveData object directly from the database.
        return userDao.load(userId);
    }

    private void refreshUser(final String userId) {
        // Runs in a background thread.
        executor.execute(() -> {
            // Check if user data was fetched recently.
            boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
            if (!userExists) {
                // Refreshes the data.
                Response<User> response = webservice.getUser(userId).execute();

                // Check for errors here.

                // Updates the database. The LiveData object automatically
                // refreshes, so we don't need to do anything else here.
                userDao.save(response.body());
            }
        });
    }
}

Perhatikan bahwa meskipun kita mengubah asal data di UserRepository, kita tidak perlu mengubah UserProfileViewModel atau UserProfileFragment. Update berskala kecil ini menunjukkan fleksibilitas yang diberikan oleh arsitektur aplikasi kita. Fleksibilitas ini juga bagus untuk pengujian, karena kita dapat memberikan UserRepository palsu dan menguji UserProfileViewModel produksi kita pada saat yang bersamaan.

Jika pengguna menunggu beberapa hari sebelum kembali ke aplikasi yang menggunakan arsitektur ini, kemungkinan mereka akan melihat informasi yang sudah tidak berlaku hingga repositori dapat mengambil informasi terbaru. Tergantung pada kasus penggunaan, Anda mungkin tidak ingin menampilkan informasi yang sudah tidak berlaku ini. Sebagai gantinya, Anda dapat menampilkan data placeholder, yang menampilkan nilai tiruan dan menunjukkan bahwa aplikasi Anda saat ini mengambil dan memuat informasi terbaru.

Sumber ketepatan tunggal

Sangatlah umum bagi endpoint REST API yang berbeda untuk menampilkan data yang sama. Misalnya, jika backend kita memiliki endpoint lain yang menampilkan daftar teman, objek pengguna yang sama dapat berasal dari dua endpoint API berbeda, bahkan mungkin menggunakan tingkat perincian yang berbeda. Jika UserRepository harus menampilkan respons dari permintaan Webservice apa adanya, tanpa memeriksa konsistensi, UI kita dapat menampilkan informasi yang membingungkan karena versi dan format data dari repositori akan bergantung pada endpoint yang terakhir dipanggil.

Untuk alasan ini, implementasi UserRepository kita menyimpan respons layanan web ke database. Perubahan pada database ini lalu memicu callback pada objek LiveData aktif. Dengan model ini, database berfungsi sebagai sumber ketepatan tunggal, dan bagian lain dari aplikasi mengaksesnya menggunakan UserRepository kita. Terlepas dari apakah Anda menggunakan cache disk, kami merekomendasikan agar repositori Anda menetapkan satu sumber data sebagai sumber ketepatan tunggal untuk seluruh aplikasi Anda.

Menampilkan operasi yang sedang berlangsung

Dalam beberapa kasus penggunaan, seperti operasi tarik untuk refresh, UI harus menunjukkan kepada pengguna bahwa saat ini ada operasi jaringan yang sedang berlangsung. Memisahkan tindakan UI ini dari data aktual merupakan praktik yang baik karena data mungkin diperbarui karena berbagai alasan. Misalnya, jika kita mengambil daftar teman, pengguna yang sama mungkin akan diambil kembali secara terprogram, dan ini akan memicu pembaruan LiveData<User>. Dari perspektif UI, adanya permintaan yang sedang berlangsung ini hanyalah titik data lain, sama seperti penggalan data lain mana pun dalam objek User itu sendiri.

Kita dapat menggunakan salah satu strategi berikut untuk menampilkan status pembaruan data yang konsisten di UI, dari mana pun permintaan pembaruan data itu berasal:

  • Mengubah getUser() untuk menampilkan objek berjenis LiveData. Objek ini akan menyertakan status operasi jaringan.

    Sebagai contoh, lihat implementasi NetworkBoundResource pada proyek GitHub komponen arsitektur android.

  • Menyediakan fungsi publik lain di kelas UserRepository yang dapat mengembalikan status refresh User. Opsi ini lebih baik jika Anda ingin menampilkan status jaringan di UI hanya ketika proses pengambilan data berasal dari tindakan pengguna yang eksplisit, seperti operasi tarik untuk refresh.

Menguji setiap komponen

Dalam bagian pemisahan fokus, kami menyebutkan bahwa salah satu manfaat utama dari mengikut prinsip ini adalah kemudahan untuk diuji.

Daftar berikut mencantumkan cara menguji setiap modul kode dari contoh lengkap kita:

  • Antarmuka dan interaksi pengguna: Gunakan Pengujian instrumentasi UI Android. Cara terbaik untuk membuat pengujian ini adalah dengan menggunakan library Espresso. Anda dapat membuat fragmen dan memberinya UserProfileViewModel palsu. Karena fragmen hanya berkomunikasi dengan UserProfileViewModel, memalsukan satu kelas ini sudah memadai untuk menguji sepenuhnya UI aplikasi Anda.

  • ViewModel: Anda dapat menguji kelas UserProfileViewModel menggunakan pengujian JUnit. Anda hanya perlu memalsukan satu kelas, UserRepository.

  • UserRepository: Anda juga dapat menguji UserRepository menggunakan pengujian JUnit test. Anda perlu memalsukan Webservice dan UserDao. Dalam pengujian ini, verifikasi perilaku berikut:

    • Repositori membuat panggilan layanan web yang benar.
    • Repositori menyimpan hasil ke dalam database.
    • Repositori tidak membuat permintaan yang tidak perlu jika data di-cache dan sudah terbaru.

    Karena baik Webservice maupun UserDao merupakan antarmuka, Anda dapat memalsukan keduanya atau membuat implementasi palsu untuk kasus pengujian yang lebih kompleks.

  • UserDao: Uji kelas DAO menggunakan pengujian instrumentasi. Karena pengujian instrumentasi tidak memerlukan komponen UI, pengujian akan berjalan cepat.

    Untuk setiap pengujian, buat database dalam memori untuk memastikan bahwa pengujian tidak memiliki efek samping, seperti perubahan file database di disk.

    Perhatian:Room memungkinkan penentuan implementasi database sehingga Anda dapat menguji DAO dengan menyediakan implementasi JUnit dari SupportSQLiteOpenHelper. Namun, pendekatan ini tidak direkomendasikan, karena versi SQLite yang ada di perangkat mungkin berbeda dengan versi SQLite pada mesin pengembangan Anda.

  • Webservice: Dalam pengujian ini, hindari membuat panggilan jaringan ke backend Anda. Penting bagi semua pengujian, terutama yang berbasis web, untuk bebas dari pengaruh dunia luar.

    Beberapa library, termasuk MockWebServer, dapat membantu Anda membuat server lokal palsu untuk pengujian ini.

  • Artefak Pengujian: Komponen Arsitektur menyediakan artefak maven untuk mengontrol thread latar belakangnya. Artefak android.arch.core:core-testing berisi aturan JUnit berikut:

    • InstantTaskExecutorRule: Gunakan aturan ini untuk langsung menjalankan operasi latar belakang apa pun pada thread pemanggilan.
    • CountingTaskExecutorRule: Gunakan aturan ini untuk menunggu operasi latar belakang Komponen Arsitektur. Anda juga dapat mengaitkan aturan ini dengan Espresso sebagai resource nonaktif.

Praktik terbaik

Pemrograman adalah bidang kreatif, begitu juga pengembangan aplikasi Android. Ada banyak cara untuk menyelesaikan masalah, baik dengan mengomunikasikan data antara berbagai aktivitas atau fragmen, mengambil data jarak jauh dan mempertahankannya secara lokal untuk mode offline, atau sejumlah skenario umum lainnya yang ditemukan oleh aplikasi yang tidak umum.

Meskipun rekomendasi berikut tidak wajib diikuti, pengalaman kami menunjukkan bahwa dengan mengikuti rekomendasi ini basis kode Anda akan menjadi lebih tangguh, mudah diuji, dan mudah dipelihara dalam jangka panjang.

Hindari menetapkan titik masuk aplikasi Anda—seperti aktivitas, layanan, dan penerima siaran—sebagai sumber data.
Komponen ini seharusnya hanya berkoordinasi dengan komponen lain untuk mengambil subkumpulan data yang relevan dengannya. Setiap komponen aplikasi memiliki masa aktif yang singkat, tergantung pada interaksi pengguna dengan perangkatnya dan respons keseluruhan saat ini dari sistem.
Buat batasan tanggung jawab yang jelas antara berbagai modul aplikasi Anda.
Misalnya, jangan menyebarkan kode yang memuat data dari jaringan di beberapa kelas atau paket pada basis kode Anda. Demikian pula, jangan menetapkan beberapa tanggung jawab yang tidak terkait—seperti data caching dan data binding—ke dalam kelas yang sama.
Perlihatkan sesedikit mungkin dari setiap modul.
Jangan tergoda untuk membuat pintasan "itu saja" yang memperlihatkan detail implementasi internal dari satu modul. Dalam jangka pendek, Anda mungkin menghemat banyak waktu, namun Anda akan menanggung utang teknis berkali-kali lipat seiring berkembangnya basis kode Anda.
Pertimbangkan cara untuk menjadikan setiap modul mudah diuji secara terpisah.
Misalnya, memiliki API yang didefinisikan dengan baik untuk mengambil data dari jaringan akan mempermudah pengujian modul yang mempertahankan data tersebut di database lokal. Sebaliknya, jika Anda mencampur logika dari kedua modul ini di satu tempat, atau mendistribusikan kode jaringan Anda di seluruh basis kode, pengujian akan menjadi jauh lebih sulit, bahkan mustahil, dilakukan.
Fokuskan pada inti unik aplikasi Anda agar lebih menonjol dibanding aplikasi lain.
Jangan memulai dari awal dengan menuliskan kode boilerplate yang sama berulang-ulang. Sebaliknya, fokuskan waktu dan energi Anda pada hal yang membuat aplikasi Anda unik, dan biarkan Komponen Arsitektur Android dan rekomendasi library lainnya menangani boilerplate yang berulang.
Pertahankan sebanyak mungkin data yang relevan dan baru.
Dengan demikian, pengguna dapat menikmati fungsionalitas aplikasi Anda meskipun perangkat mereka dalam mode offline. Ingat, tidak semua pengguna Anda menyukai konektivitas berkecepatan tinggi dan konstan.
Tetapkan satu sumber data sebagai sumber ketepatan tunggal.
Kapan pun aplikasi Anda perlu mengakses potongan data ini, data harus selalu berasal dari sumber ketepatan tunggal ini.

Tambahan: memperlihatkan status jaringan

Di bagian arsitektur aplikasi yang direkomendasikan di atas, kami mengabaikan error jaringan dan status pemuatan agar cuplikan kode lebih ringkas.

Bagian ini menunjukkan cara memperlihatkan status jaringan menggunakan kelas Resource yang mencakup data dan status data sekaligus.

Cuplikan kode berikut menunjukkan contoh implementasi Resource:

// A generic class that contains data and status about loading this data.
public class Resource<T> {
    @NonNull public final Status status;
    @Nullable public final T data;
    @Nullable public final String message;
    private Resource(@NonNull Status status, @Nullable T data,
            @Nullable String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    public static <T> Resource<T> success(@NonNull T data) {
        return new Resource<>(Status.SUCCESS, data, null);
    }

    public static <T> Resource<T> error(String msg, @Nullable T data) {
        return new Resource<>(Status.ERROR, data, msg);
    }

    public static <T> Resource<T> loading(@Nullable T data) {
        return new Resource<>(Status.LOADING, data, null);
    }

    public enum Status { SUCCESS, ERROR, LOADING }
}

Karena memuat data dari jaringan sambil menampilkan salinan disk dari data tersebut merupakan praktik yang umum, sebaiknya buat kelas penunjang yang dapat Anda gunakan kembali di beberapa tempat. Untuk contoh ini, kami membuat kelas yang disebut NetworkBoundResource.

Diagram berikut menunjukkan hierarki keputusan untuk NetworkBoundResource:

Prosesnya dimulai dengan mengamati database untuk resource. Ketika entri pertama kali dimuat dari database, NetworkBoundResource memeriksa apakah hasilnya cukup bagus untuk dikirim, atau harus diambil ulang dari jaringan. Perhatikan bahwa kedua situasi ini dapat terjadi secara bersamaan, mengingat bahwa Anda mungkin ingin menampilkan data yang di-cache sambil memperbaruinya dari jaringan.

Jika panggilan jaringan sukses, panggilan menyimpan respons ke dalam database dan menginisialisasi ulang aliran. Jika permintaan jaringan gagal, NetworkBoundResource akan langsung mengirimkan kegagalan.

Catatan: Setelah menyimpan data baru ke disk, kami menginisialisasi ulang aliran ini dari database. Namun, biasanya kami tidak perlu melakukan langkah ini karena database dengan sendirinya mengirimkan perubahan.

Perlu diingat bahwa mengandalkan database untuk mengirimkan perubahan berarti mengandalkan efek samping yang terkait, dan ini bukan praktik yang baik karena perilaku yang tidak diketahui dari efek samping tersebut dapat muncul jika database tidak mengirimkan perubahan karena data tidak berubah.

Selain itu, jangan mengirim hasil yang diterima dari jaringan karena hal itu akan melanggar prinsip sumber ketepatan tunggal. Toh, bisa jadi database menyertakan pemicu yang mengubah nilai data selama operasi "penyimpanan". Demikian pula, jangan mengirim SUCCESS tanpa data baru, karena klien akan menerima versi data yang salah.

Cuplikan kode berikut menampilkan API publik yang disediakan oleh kelas NetworkBoundResource untuk turunannya:

// ResultType: Type for the Resource data.
// RequestType: Type for the API response.
public abstract class NetworkBoundResource<ResultType, RequestType> {
    // Called to save the result of the API response into the database.
    @WorkerThread
    protected abstract void saveCallResult(@NonNull RequestType item);

    // Called with the data in the database to decide whether to fetch
    // potentially updated data from the network.
    @MainThread
    protected abstract boolean shouldFetch(@Nullable ResultType data);

    // Called to get the cached data from the database.
    @NonNull @MainThread
    protected abstract LiveData<ResultType> loadFromDb();

    // Called to create the API call.
    @NonNull @MainThread
    protected abstract LiveData<ApiResponse<RequestType>> createCall();

    // Called when the fetch fails. The child class may want to reset components
    // like rate limiter.
    @MainThread
    protected void onFetchFailed();

    // Returns a LiveData object that represents the resource that's implemented
    // in the base class.
    public final LiveData<Resource<ResultType>> getAsLiveData();
}

Perhatikan detail penting berikut tentang definisi kelas:

  • Ada dua jenis parameter yang ditetapkan, ResultType dan RequestType, karena jenis data yang ditampilkan dari API mungkin tidak cocok dengan jenis data yang digunakan secara lokal.
  • Kelas ApiResponse digunakan untuk permintaan jaringan. ApiResponse adalah wrapper ringkas untuk kelas Retrofit2.Call yang mengonversi respons terhadap instance LiveData.

Implementasi kelas NetworkBoundResource secara lengkap muncul sebagai bagian dari proyek GitHub komponen arsitektur android.

Setelah membuat NetworkBoundResource, kita dapat menggunakannya untuk menulis implementasi User terikat disk dan terikat jaringan di kelas UserRepository:

UserRepository

class UserRepository {
    Webservice webservice;
    UserDao userDao;

    public LiveData<Resource<User>> loadUser(final int userId) {
        return new NetworkBoundResource<User,User>() {
            @Override
            protected void saveCallResult(@NonNull User item) {
                userDao.insert(item);
            }

            @Override
            protected boolean shouldFetch(@Nullable User data) {
                return rateLimiter.canFetch(userId)
                        && (data == null || !isFresh(data));
            }

            @NonNull @Override
            protected LiveData<User> loadFromDb() {
                return userDao.load(userId);
            }

            @NonNull @Override
            protected LiveData<ApiResponse<User>> createCall() {
                return webservice.getUser(userId);
            }
        }.getAsLiveData();
    }
}