Jetpack 练手项目 —— Sunflower

Sunflower 是 Google 官方提供的 Jetpack 练手项目,起初是用 Java 实现的,并逐步演进为现在的完全由 Kotlin 实现。它的页面如下:

Sunflower 效果图
图一展示在“我的花园”中已经种植了哪些植物,点击左上角的菜单按钮会弹出图二的抽屉布局,菜单中有“我的花园”和“植物目录”两个选项,点击植物目录会进入图三展示所有植物种类的页面,点击某一种植物后会进入图四的植物详情页面,在该页面点击右下角按钮后会将其添加到图一“我的花园”页面中。

本篇文章会介绍 Java 实现该 Demo 的过程。

1、初始页面搭建

按照 Google 对 Android 应用的构想,一个应用中只有一个 Activity,页面切换实际上是 Fragment 的切换。所以我们整个应用就只有主页一个 GardenActivity:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.drawerlayout.widget.DrawerLayout
        android:id="@+id/drawer_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <!--Activity的内容布局,包括Toolbar以及下面的Fragment容器-->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <com.google.android.material.appbar.AppBarLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">

                <androidx.appcompat.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize" />
            </com.google.android.material.appbar.AppBarLayout>

            <!--Fragment容器使用Navigation进行导航-->
            <fragment
                android:id="@+id/garden_nav_fragment"
                android:name="androidx.navigation.fragment.NavHostFragment"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:defaultNavHost="true"
                app:navGraph="@navigation/nav_garden" />
        </LinearLayout>

        <com.google.android.material.navigation.NavigationView
            android:id="@+id/navigation_view"
            style="@style/Widget.Design.NavigationView"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_gravity="start"
            app:headerLayout="@layout/nav_header"
            app:menu="@menu/menu_navigation" />
    </androidx.drawerlayout.widget.DrawerLayout>
</layout>

GardenActivity 布局中牵扯到两个问题:

  1. Fragment 的导航图
  2. NavigationView 的内容

@navigation/nav_garden 中定义三个 Fragment 以及 Fragment 之间跳转的 action:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_garden"
    app:startDestination="@id/garden_fragment">

    <fragment
        android:id="@+id/garden_fragment"
        android:name="com.frank.demo.sunflower.fragments.GardenFragment"
        android:label="@string/my_garden_title">

        <action
            android:id="@+id/action_garden_fragment_to_plant_detail_fragment"
            app:destination="@id/plant_detail_fragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"
            app:popEnterAnim="@anim/slide_in_left"
            app:popExitAnim="@anim/slide_out_right" />
    </fragment>

    <fragment
        android:id="@+id/plant_list_fragment"
        android:name="com.frank.demo.sunflower.fragments.PlantListFragment"
        android:label="@string/plant_list_title">
        <action
            android:id="@+id/action_plant_list_fragment_to_plant_detail_fragment"
            app:destination="@id/plant_detail_fragment"
            app:enterAnim="@anim/slide_in_right"
            app:exitAnim="@anim/slide_out_left"
            app:popEnterAnim="@anim/slide_in_left"
            app:popExitAnim="@anim/slide_out_right" />
    </fragment>

    <fragment
        android:id="@+id/plant_detail_fragment"
        android:name="com.frank.demo.sunflower.fragments.PlantDetailFragment"
        android:label="@string/plant_details_title">
    </fragment>

必须通过 app:startDestination 属性指定初始目的地,否则会出现运行时异常。

至于 NavigationView,通过 app:menu=“@menu/menu_navigation” 指定目录资源,也就是图二中的两个选项:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/garden_fragment"
        android:title="@string/my_garden_title" />

    <item
        android:id="@+id/plant_list_fragment"
        android:title="@string/plant_list_title" />
</menu>

需要注意的是 Menu 中的 item 的 id 必须与导航图中定义的对应 Fragment 的 id 相同,这样在点击菜单选项时才能进行导航。

最后要在 GardenActivity 中通过 NavigationUI 配置其对 NavigationView 和 ActionBar 的控制:

public class GardenActivity extends AppCompatActivity {

    private NavController navController;
    private AppBarConfiguration appBarConfiguration;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // 两种创建 ActivityGardenBinding 的方式皆可
        ActivityGardenBinding binding = ActivityGardenBinding.inflate(getLayoutInflater());
//        ActivityGardenBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_garden);
        setContentView(binding.getRoot());

        navController = Navigation.findNavController(this, R.id.garden_nav_fragment);
        appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph())
                .setOpenableLayout(binding.drawerLayout)
                .build();
        setSupportActionBar(binding.toolbar);
        NavigationUI.setupWithNavController(binding.navigationView, navController);
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);
    }

    /**
     * 不重写点击Toolbar左上角菜单不会打开抽屉布局
     */
    @Override
    public boolean onSupportNavigateUp() {
        return NavigationUI.navigateUp(navController, appBarConfiguration) || super.onSupportNavigateUp();
    }
}

完整的提交纪录参考 GitHub 92af93a 和 37825bd。

2、植物目录页面

页面内容都是需要数据支撑的。数据源可以是数据库、网络或者本地数据,按照 Google 推荐的做法,需要将数据做成一个数据层,从下到上依次为数据源 -> 数据仓库 -> LiveData -> ViewModel:


在本 Demo 中,我们会从一个已经建立好的 plant.json 文件中读取其中的数据存入数据库,LiveData 会借助 Repository 访问数据库获取相关数据。

2.1 数据层搭建

首先要建立数据模型类 Plant,将其做成 Room 数据库的 plants 表:

@Entity(tableName = "plants")
public class Plant {

    // 默认浇水周期
    private static final int DEFAULT_WATERING_INTERVAL = 7;

    @NonNull
    @PrimaryKey
    @ColumnInfo(name = "id")
    private final String plantId;

    @NonNull
    private final String name;

    @NonNull
    private final String description;

    private final int growZoneNumber;

    // 植物应该多久浇水一次,以天为单位
    private final int wateringInterval;

    @NonNull
    private final String imageUrl;

    public Plant(@NonNull String plantId, @NonNull String name, @NonNull String description,
                 int growZoneNumber, int wateringInterval, String imageUrl) {
        this.plantId = plantId;
        this.name = name;
        this.description = description;
        this.growZoneNumber = growZoneNumber;
        this.wateringInterval = wateringInterval > 0 ? wateringInterval : DEFAULT_WATERING_INTERVAL;
        this.imageUrl = imageUrl;
    }

	// getters and equals()、hashCode()...
}

有了数据库表之后,就是在 Dao 层中封装对数据库的各种操作:

/**
 * The Data Access Object for the Plant class.
 */
@Dao
public interface PlantDao {

    @Query("SELECT * FROM plants ORDER BY name")
    LiveData<List<Plant>> getPlants();

    @Query("SELECT * FROM plants WHERE growZoneNumber = :growZoneNumber ORDER BY name")
    LiveData<List<Plant>> getPlantsWithGrowZoneNumber(int growZoneNumber);

    @Query("SELECT * FROM plants WHERE id = :plantId")
    LiveData<Plant> getPlant(String plantId);

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insertAll(List<Plant> plants);
}

可以直接访问 Dao 层的不是最上层的 ViewModel,而是数据仓库 PlantRepository:

public class PlantRepository {

    private static PlantRepository instance;

    private PlantDao plantDao;

    private PlantRepository(PlantDao plantDao) {
        this.plantDao = plantDao;
    }

    public static PlantRepository getInstance(PlantDao plantDao) {
        if (instance == null) {
            synchronized (PlantRepository.class) {
                if (instance == null) {
                    instance = new PlantRepository(plantDao);
                }
            }
        }
        return instance;
    }

    public LiveData<List<Plant>> getPlants() {
        return plantDao.getPlants();
    }

    public LiveData<Plant> getPlant(String plantId) {
        return plantDao.getPlant(plantId);
    }

    public LiveData<List<Plant>> getPlantsWithGrowZoneNumber(int growZoneNumber) {
        return plantDao.getPlantsWithGrowZoneNumber(growZoneNumber);
    }
}

ViewModel 只会通过数据仓库获取数据:

/**
 * PlantListViewModel 主要保持两个数据,一是展示植物列表的 plants,
 * 二是表示植物生长周期的 growZoneNumber,该值默认为 NO_GROW_ZONE,
 * 当给 growZoneNumber 设置了一个值之后,当前页面就仅展示生长周期为
 * growZoneNumber 的植物
 */
public class PlantListViewModel extends ViewModel {

    private static final int NO_GROW_ZONE = -1;

    public LiveData<List<Plant>> plants;

    private MutableLiveData<Integer> growZoneNumber;

    public PlantListViewModel(PlantRepository plantRepository) {
        growZoneNumber = new MutableLiveData<>(NO_GROW_ZONE);

		// 在 ViewModel 中拿不到 LifecycleOwner,所以无法直接使用 growZoneNumber.observe() 监听
		// 数据变化,所以使用 Transformations 对 growZoneNumber 进行筛选,不同条件返回不同的 plants。
		// 所以说监听数据变化不止可以在 Activity/Fragment 中才可以,ViewModel 内部也可以。
        plants = Transformations.switchMap(growZoneNumber, integer -> {
            // 没有设置 growZoneNumber 就按照默认方式获取所有植物,否则按照指定值获取植物
            // 可以看到只通过 plantRepository 获取植物相关数据
            if (integer == NO_GROW_ZONE) {
                return plantRepository.getPlants();
            } else {
                return plantRepository.getPlantsWithGrowZoneNumber(integer);
            }
        });
    }

    public void setGrowZoneNumber(int growZoneNumber) {
        this.growZoneNumber.setValue(growZoneNumber);
    }

    public void clearGrowZoneNumber() {
        growZoneNumber.setValue(NO_GROW_ZONE);
    }

    public boolean isFiltered() {
        return growZoneNumber.getValue() != null && growZoneNumber.getValue() != NO_GROW_ZONE;
    }
}

到这里数据层就按照由下至上的顺序搭建好了,只不过还有一个数据来源问题,在本例中,我们采用读取事先编辑好的 json 文件的形式模拟网络通信,将 json 文件中的内容读取成 List<Plant> 类型插入到数据库中,这项工作在创建数据库时交给 WorkManager 去做:

@Database(entities = {Plant.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {

    private static volatile AppDatabase instance;

    public static AppDatabase getInstance(Context context) {
        if (instance == null) {
            synchronized (AppDatabase.class) {
                instance = buildDatabase(context);
            }
        }
        return instance;
    }

    private static AppDatabase buildDatabase(Context context) {
        return Room.databaseBuilder(context,AppDatabase.class, Constants.DATABASE_NAME)
                .addCallback(new Callback() {
                    @Override
                    public void onCreate(@NonNull SupportSQLiteDatabase db) {
                        super.onCreate(db);
                        // 创建数据库时将 assets/plants.json 内的数据插入数据库中
                        WorkManager.getInstance(context).enqueue(OneTimeWorkRequest.from(SeedDatabaseWorker.class));
                    }
                })
                .build();
    }

    public abstract PlantDao getPlantDao();
}
--------------------------------------------------------------------------------------------
public class SeedDatabaseWorker extends Worker {

    private static final String TAG = SeedDatabaseWorker.class.getSimpleName();

    public SeedDatabaseWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
    }

    @NonNull
    @Override
    public Result doWork() {
        try {
            // 通过 Gson 将 assets/plants.json 文件的输入流解析成 List<Plant>
            InputStream inputStream = getApplicationContext().getAssets().open(Constants.PLANT_DATA_FILENAME);
            JsonReader jsonReader = new JsonReader(new InputStreamReader(inputStream));
            Type plantType = new TypeToken<List<Plant>>() {}.getType();
            List<Plant> plantList = new Gson().fromJson(jsonReader, plantType);

            // 将 plantList 插入数据库
            AppDatabase appDatabase = AppDatabase.getInstance(getApplicationContext());
            appDatabase.getPlantDao().insertAll(plantList);
            return Result.success();
        } catch (IOException e) {
            Log.e(TAG, "Error seeding database from assets/plants.json,detail:", e);
            return Result.failure();
        }
    }
}

2.2 PlantListViewModel 的创建

一般情况下我们会直接使用 ViewModelProvider 创建 ViewModel 对象:

	PlantListViewModel plantListViewModel = new ViewModelProvider(this).get(PlantListViewModel.class);

但是通过这种方式最终会通过反射的方式创建对应的 ViewModel 对象,为了规避反射,我们需要创建一个 PlantListViewModelFactory 来提供 PlantListViewModel 对象:

public class PlantListViewModelFactory extends ViewModelProvider.NewInstanceFactory {

    private PlantRepository plantRepository;

    public PlantListViewModelFactory(PlantRepository plantRepository) {
        this.plantRepository = plantRepository;
    }

    @NonNull
    @Override
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
    	// 直接创建一个 PlantListViewModel 对象而不使用反射
        return (T) new PlantListViewModel(plantRepository);
    }
}

使用一个工具类提供 PlantListViewModelFactory 对象:

public class InjectUtils {

    public static PlantRepository getPlantRepository(Context context) {
        PlantDao plantDao = AppDatabase.getInstance(context.getApplicationContext()).getPlantDao();
        return PlantRepository.getInstance(plantDao);
    }

    public static PlantListViewModelFactory providePlantListViewModelFactory(Context context) {
        return new PlantListViewModelFactory(getPlantRepository(context));
    }
}

最后用如下方式创建 PlantListViewModel 对象:

	PlantListViewModelFactory factory = InjectUtils.providePlantListViewModelFactory(requireContext());
    PlantListViewModel plantListViewModel = new ViewModelProvider(this, factory).get(PlantListViewModel.class);

2.3 Fragment 页面

数据准备好了,接下来就是页面了,首先是 Fragment 的布局,只有一个展示植物列表的 RecyclerView:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/plant_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:paddingStart="@dimen/margin_normal"
        android:paddingEnd="@dimen/margin_normal"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:context="com.frank.demo.sunflower.GardenActivity"
        tools:listitem="@layout/list_item_plant" />
</layout>

重点是 RecyclerView Item 的布局:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="plant"
            type="com.frank.demo.sunflower.data.Plant" />

        <variable
            name="click"
            type="android.view.View.OnClickListener" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="@{click}">

        <ImageView
            android:id="@+id/plant_item_image"
            android:layout_width="0dp"
            android:layout_height="@dimen/plant_item_image_height"
            android:layout_margin="@dimen/margin_small"
            android:contentDescription="@string/a11y_plant_item_image"
            android:scaleType="centerCrop"
            app:imageFromUrl="@{plant.imageUrl}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/margin_small"
            android:text="@{plant.name}"
            android:textAppearance="?attr/textAppearanceListItem"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/plant_item_image"
            tools:text="Tomato" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

在 RecyclerView Item 对应的适配器 PlantAdapter 中,需要注意一点,就是绑定数据的时候需要将真实数据通过 setter 方法设置给布局文件中 variable 标签中定义的变量:

public class PlantAdapter extends ListAdapter<Plant, PlantAdapter.ViewHolder> {

    public PlantAdapter() {
        super(new PlantDiffCallback());
    }

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        // ListItemPlantBinding 是根据 Item 布局文件 list_item_plant 生成的
        return new ViewHolder(ListItemPlantBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false));
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Plant plant = getItem(position);
//        holder.itemView.setTag(plant);
        holder.bind(plant);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        private ListItemPlantBinding binding;

        public ViewHolder(ListItemPlantBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }

        public void bind(Plant plant) {
            // 将 plant 设置给 Item 布局文件中定义的变量
            binding.setPlant(plant);
//            binding.executePendingBindings();
        }
    }

    static class PlantDiffCallback extends DiffUtil.ItemCallback<Plant> {

        @Override
        public boolean areItemsTheSame(@NonNull Plant oldItem, @NonNull Plant newItem) {
            return oldItem.getPlantId().equals(newItem.getPlantId());
        }

        @SuppressLint("DiffUtilEquals")
        @Override
        public boolean areContentsTheSame(@NonNull Plant oldItem, @NonNull Plant newItem) {
            return oldItem == newItem;
        }
    }
}

我们暂时只通过 setPlant() 将 Plant 的数据给到布局文件中的 plant 变量了,而没有给 click 变量值,这是因为点击 Item 会进入植物详情页面,需要传参,关于传参的内容我们在下一节中再介绍。

这样最后来到 PlantListFragment,使用 PlantAdapter 作为 RecyclerView 的适配器,通过 PlantListViewModel 内的 plants 观察数据变化,并更新 PlantAdapter 展示的数据:

public class PlantListFragment extends Fragment {

    private PlantListViewModel plantListViewModel;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        FragmentPlantListBinding binding = FragmentPlantListBinding.inflate(inflater, container, false);

        // 创建 plantListViewModel
        PlantListViewModelFactory factory = InjectUtils.providePlantListViewModelFactory(requireContext());
        plantListViewModel = new ViewModelProvider(this, factory).get(PlantListViewModel.class);

        // 设置适配器并绑定数据
        PlantAdapter plantAdapter = new PlantAdapter();
        binding.plantList.setAdapter(plantAdapter);
        plantListViewModel.plants.observe(getViewLifecycleOwner(), new Observer<List<Plant>>() {
            @Override
            public void onChanged(List<Plant> plants) {
                plantAdapter.submitList(plants);
            }
        });

        setHasOptionsMenu(true);

        return binding.getRoot();
    }

    @Override
    public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
        inflater.inflate(R.menu.menu_plant_list, menu);
    }

    @Override
    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
        switch (item.getItemId()) {
            case R.id.filter_zone:
                updateData();
                return true;
        }
        return super.onOptionsItemSelected(item);
    }

    private void updateData() {
        if (plantListViewModel.isFiltered()) {
            plantListViewModel.clearGrowZoneNumber();
        } else {
            // 演示例子,随便设置一个生长周期为 9 天的植物
            plantListViewModel.setGrowZoneNumber(9);
        }
    }
}

菜单的相关代码,是以 PlantListViewModel 内的 growZoneNumber 为筛选条件,过滤要展示的植物列表。默认不进行筛选,点击一次菜单图标后,会展示所有生长周期为 9 天的植物。

还有一个关键点是图片的展示,在布局文件定义 ImageView 时使用 app:imageFromUrl=“@{plant.imageUrl}” 将植物图片的 URL 赋给了该属性,我们需要对其进行处理:

public class PlantBindingAdapter {

    @BindingAdapter("imageFromUrl")
    public static void bindImageFromUrl(ImageView view, String imageUrl) {
        if (!TextUtils.isEmpty(imageUrl)) {
            Glide.with(view.getContext())
                    .load(imageUrl)
                    .transition(DrawableTransitionOptions.withCrossFade())
                    .into(view);
        }
    }
}

使用 @BindingAdapter 标记处理 imageFromUrl 属性的方法,第一个参数是接收参数的类型,第二个参数是被接收的参数,在这里就是 ImageView 接收 imageUrl,使用 Glide 加载该图片并显示即可。

本节提交纪录为 0ef18c8 可供参考。

3、我的花园页面

该页面展示的是已经被种植的植物的信息列表,包括植物图片、种植的时间以及浇水时间等信息。还是先从数据层面考虑,它不能直接使用上一节的 Plant 实体做成的数据库表 plants,而是需要在 plants 基础上新建一个表,添加日期相关信息。

3.1 数据层搭建

还是由下至上,先通过 GardenPlanting 实体建立一个新的数据库表 garden_plantings,用以存储被种植的植物的相关信息:

/**
* garden_plantings 的 plant_id 使用的是 plants 表的主键 id,需要通过 foreignKeys 指定
* 外键的类对象,parentColumns 是外键被引用的列名,childColumns 是对应在本表中的列名。
* 此外以 indices 指定以哪一列作为索引,以加快查询速度
*/
@Entity(tableName = "garden_plantings",
        foreignKeys = {@ForeignKey(entity = Plant.class, parentColumns = {"id"}, childColumns = {"plant_id"})},
        indices = {@Index("plant_id")})
public class GardenPlanting {

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id")
    private long gardenPlantingId = 0L;

    @ColumnInfo(name = "plant_id")
    private final String plantId;

    @ColumnInfo(name = "plant_date")
    private final Calendar plantDate;

    @ColumnInfo(name = "last_watering_date")
    private final Calendar lastWateringDate;
	
	...
}

此外,由于我的花园页面中的每个条目所需的信息,是从 plants 和 garden_plantings 两个表中查询出来的,即植物图片、名称、浇水间隔取自 plants,而植物的种植和上次浇水的日期取自 garden_plantings,所以查询时不止进行一次查询(两个表至少各查询一次),并且还需要一个新的数据类来装这个查询结果:

/**
 * 这个类是存放从 plants,garden_plantings 两个数据库表中查询出来的结果的,
 * 从 plants 中查询出的结果是 Plant,从 garden_plantings 中查出的结果是 GardenPlanting,
 * 二者是一对多的关系,所以按照如下方式定义成员。详情可参考官方指南【定义对象之间的关系】:
 * https://developer.android.google.cn/training/data-storage/room/relationships#one-to-many
 */
public class PlantAndGardenPlanting {

	// 使用 @Embedded 可以让 Plant 的各个字段以展开后的形式保存在数据库中
    @Embedded
    private Plant plant;

	// 定义数据库关系时需让子实体引用父实体的主键,用 @Relation 标记这种关系,
	// parentColumn 是父实体的主键,entityColumn 是子实体中引用了父实体的列
    @Relation(parentColumn = "id", entityColumn = "plant_id")
    private List<GardenPlanting> gardenPlantings;

    public PlantAndGardenPlanting() {
        gardenPlantings = new ArrayList<>();
    }

    public Plant getPlant() {
        return plant;
    }

    public void setPlant(Plant plant) {
        this.plant = plant;
    }

    public List<GardenPlanting> getGardenPlantings() {
        return gardenPlantings;
    }

    public void setGardenPlantings(List<GardenPlanting> gardenPlantings) {
        this.gardenPlantings = gardenPlantings;
    }
}

这个步骤是按照 Google 官方推荐的方法来做的,详情要参考定义对象之间的关系。简单说,PlantAndGardenPlanting 内通过 @Relation 注解定义了 Plant 与 GardenPlanting 之间一对多的关系,至于为什么是一对多而不是一对一,个人将其理解成,一种 Plant 是可以多次种植产生多个 GardenPlanting 的,因此是一对多,只不过在软件功能设计上,一种植物在被种植后其页面上就不会再显示出 + 按钮进而保证一种植物只能被种植一次,但是数据库设计上还是将其设置为一对多。

假如允许一种植物被多次种植,那么数据库查询出来的内容是:


可以看到红框内的植物被种植了三次,那么查询结果的 List<GardenPlanting> gardenPlantings 内就有三个元素,分别在不同的三个时间被种植。

这样数据类型就算准备好了,向上一层,在 Dao 层中要准备出查询和插入方法:

@Dao
public interface GardenPlantingDao {

    /**
     * 查询所有被种植的植物列表
     */
    @Query("SELECT * FROM garden_plantings")
    LiveData<List<GardenPlanting>> getGardenPlantings();

    /**
     * This query will tell Room to query both the [Plant] and [GardenPlanting] tables and handle the object mapping.
     * 查询所有 PlantAndGardenPlanting,这个数据类型是展示我的花园页面被种植的植物列表会用到的
     */
    @Transaction // 一个方法的查询次数超过一次需要加此注解以保证查询的原子性
    @Query("SELECT * FROM plants")
    LiveData<List<PlantAndGardenPlanting>> getPlantAndGardenPlantings();
    
    @Insert
    long insertGardenPlanting(@NonNull GardenPlanting gardenPlanting);
}

再向上一层是 GardenPlanting 仓库:

public class GardenPlantingRepository {

    private static GardenPlantingRepository instance;

    private GardenPlantingDao gardenPlantingDao;

    private GardenPlantingRepository(GardenPlantingDao gardenPlantingDao) {
        this.gardenPlantingDao = gardenPlantingDao;
    }

    public static GardenPlantingRepository getInstance(GardenPlantingDao gardenPlantingDao) {
        if (instance == null) {
            synchronized (PlantRepository.class) {
                if (instance == null) {
                    instance = new GardenPlantingRepository(gardenPlantingDao);
                }
            }
        }
        return instance;
    }

    public LiveData<List<GardenPlanting>> getGardenPlantings() {
        return gardenPlantingDao.getGardenPlantings();
    }

    public LiveData<List<PlantAndGardenPlanting>> getPlantAndGardenPlantings() {
        return gardenPlantingDao.getPlantAndGardenPlantings();
    }

	// 插入方法暂时先省略,【4.2】节会讲...
}

再向上,由 ViewModel 操作仓库:

/**
 * 为我的花园页面提供数据的 ViewModel,该页面需要的是被种植的植物列表,
 */
public class GardenPlantingListViewModel extends ViewModel {

    public LiveData<List<GardenPlanting>> gardenPlantings;
    public LiveData<List<PlantAndGardenPlanting>> plantAndGardenPlantings;

    public GardenPlantingListViewModel(GardenPlantingRepository gardenPlantingRepository) {
        gardenPlantings = gardenPlantingRepository.getGardenPlantings();
        plantAndGardenPlantings = Transformations.map(gardenPlantingRepository.getPlantAndGardenPlantings(),
                plantAndGardenPlantings -> {
                    // 如果一种植物的 PlantAndGardenPlanting 中的 gardenPlantings 为空,说明该植物没有被种植,
                    // 需要从查询结果中删除掉该种植物
                    List<PlantAndGardenPlanting> removeItems = new ArrayList<>();
                    for (PlantAndGardenPlanting planting : plantAndGardenPlantings) {
                        if (planting.getGardenPlantings() == null || planting.getGardenPlantings().isEmpty()) {
                            removeItems.add(planting);
                        }
                    }
                    plantAndGardenPlantings.removeAll(removeItems);
                    return plantAndGardenPlantings;
                });
    }
}

GardenPlantingListViewModel 是为 GardenFragment 提供列表数据的,将 PlantAndGardenPlanting 类型的列表提供给 Adapter 后,还需要另一个 ViewModel 为 Adapter 中的列表项提供 PlantAndGardenPlanting 数据:

/**
 * 为我的花园页面中的 RecyclerView Item 提供数据的 ViewModel
 */
public class PlantAndGardenPlantingsViewModel extends ViewModel {

    public ObservableField<String> imageUrl;
    public ObservableField<String> plantName;
    public ObservableField<String> plantDate;
    public ObservableField<String> wateringDate;
    public ObservableInt wateringInterval;

    @SuppressLint("RestrictedApi")
    public PlantAndGardenPlantingsViewModel(PlantAndGardenPlanting plantings) {
        Plant plant = Preconditions.checkNotNull(plantings.getPlant());
        // 由于软件设计上一个植物只允许被种植一次,所以去拿数据库中最早的种植记录即可
        GardenPlanting gardenPlanting = plantings.getGardenPlantings().get(0);
        DateFormat dateFormat = new SimpleDateFormat("MMM d, yyyy", Locale.US);

        this.imageUrl = new ObservableField<>(plant.getImageUrl());
        this.plantName = new ObservableField<>(plant.getName());
        this.plantDate = new ObservableField<>(dateFormat.format(gardenPlanting.getPlantDate().getTime()));
        this.wateringDate = new ObservableField<>(dateFormat.format(gardenPlanting.getLastWateringDate().getTime()));
        this.wateringInterval = new ObservableInt(plant.getWateringInterval());
    }
}

两个 ViewModel 只有 GardenPlantingListViewModel 需要用一个自定义工厂创建其实例对象,该工厂的写法可以参考前面,非常简单,这里就不赘述了。到这里数据层的主要工作算是完成了,此外还有一些需要注意的地方,比如我们在 GardenPlanting 中定义了两个日期相关的变量:

	@ColumnInfo(name = "plant_date")
    private final Calendar plantDate;

    @ColumnInfo(name = "last_watering_date")
    private final Calendar lastWateringDate;

Calendar 类型的数据是无法直接保存在数据库中的,需要一个转换器进行转换:

/**
 * Type converters to allow Room to reference complex data types.
 * Cannot figure out how to save this field into database. You can consider adding a type
 *   例如:
 *   class GardenPlanting {
 *       @ColumnInfo(name = "plant_date") private final Calendar plantDate;   编译时报错
 *       @ColumnInfo(name = "last_watering_date") private final Calendar lastWateringDate; 编译时报错
 *   }
 */
public class Converters {

    @TypeConverter
    public long calendarToDateStamp(Calendar calendar) {
        return calendar.getTimeInMillis();
    }

    @TypeConverter
    public Calendar dateStampToCalendar(long timeInMillis) {
        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(timeInMillis);
        return calendar;
    }
}

并在数据库文件 AppDatabase 用 @TypeConverters 标明所使用的转换器类:

@TypeConverters(Converters.class)
@Database(entities = {Plant.class, GardenPlanting.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
}

3.2 Fragment 页面

先看 GardenFragment 的布局:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="hasPlantings"
            type="boolean" />
    </data>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/garden_list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:clipToPadding="false"
            android:paddingStart="@dimen/margin_normal"
            android:paddingEnd="@dimen/margin_normal"
            app:isGone="@{!hasPlantings}"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

        <TextView
            android:id="@+id/empty_garden"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:text="@string/garden_empty"
            android:textSize="24sp"
            app:isGone="@{hasPlantings}" />
    </FrameLayout>
</layout>

data 部分只定义了一个变量 hasPlantings 表示花园中是否有植物,如有则用 RecyclerView 展示植物列表,否则用 TextView 告知花园中当前没有植物。

再来看 GardenFragment 的逻辑代码:

public class GardenFragment extends Fragment {

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        FragmentGardenBinding binding = FragmentGardenBinding.inflate(inflater, container, false);
        // 设置 GardenPlantingAdapter
        GardenPlantingAdapter adapter = new GardenPlantingAdapter();
        binding.gardenList.setAdapter(adapter);
        // 绑定数据
        subscribeUi(adapter, binding);

        return binding.getRoot();
    }

    private void subscribeUi(GardenPlantingAdapter adapter, FragmentGardenBinding binding) {
        // 获取 GardenPlantingListViewModel
        GardenPlantingListViewModelFactory factory = InjectUtils.provideGardenPlantingListViewModelFactory(requireContext());
        GardenPlantingListViewModel viewModel = new ViewModelProvider(this, factory).get(GardenPlantingListViewModel.class);

        // 设置是否有植物被种植到花园中
        viewModel.gardenPlantings.observe(getViewLifecycleOwner(), new Observer<List<GardenPlanting>>() {
            @Override
            public void onChanged(List<GardenPlanting> gardenPlantings) {
            	// 给布局中的 hasPlantings 变量传值
                binding.setHasPlantings(gardenPlantings != null && !gardenPlantings.isEmpty());
            }
        });

        // 设置花园中的植物列表
        viewModel.plantAndGardenPlantings.observe(getViewLifecycleOwner(), new Observer<List<PlantAndGardenPlanting>>() {
            @Override
            public void onChanged(List<PlantAndGardenPlanting> plantAndGardenPlantings) {
                if (plantAndGardenPlantings != null && !plantAndGardenPlantings.isEmpty()) {
                    adapter.submitList(plantAndGardenPlantings);
                }
            }
        });
    }
}

关键点在于给 GardenPlantingListViewModel 的 gardenPlantings 和 plantAndGardenPlantings 设置数据监听,改变整体页面显示和 RecyclerView 列表项的显示内容。

GardenPlantingAdapter 接收到 GardenFragment 传过来的 List<PlantAndGardenPlanting>,需要对数据进行适配,先看列表项的布局:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="viewModel"
            type="com.frank.demo.sunflower.viewmodels.PlantAndGardenPlantingsViewModel" />

        <variable
            name="click"
            type="android.view.View.OnClickListener" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="@{click}">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="match_parent"
            android:layout_height="@dimen/plant_item_image_height"
            android:layout_marginStart="@dimen/margin_small"
            android:layout_marginTop="@dimen/margin_normal"
            android:layout_marginEnd="@dimen/margin_small"
            android:contentDescription="@string/a11y_plant_item_image"
            android:scaleType="centerCrop"
            app:imageFromUrl="@{viewModel.imageUrl}"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.0" />

        <TextView
            android:id="@+id/plant_date"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/margin_small"
            android:layout_marginTop="@dimen/margin_normal"
            android:layout_marginEnd="@dimen/margin_small"
            android:text="@{@string/planted_date(viewModel.plantName,viewModel.plantDate)}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/imageView" />

        <TextView
            android:id="@+id/watering_date"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/margin_small"
            android:layout_marginTop="@dimen/margin_normal"
            android:layout_marginEnd="@dimen/margin_small"
            android:text="@{@string/water_date(@string/watering_next_prefix(viewModel.wateringDate),@plurals/watering_next_suffix(viewModel.wateringInterval,viewModel.wateringInterval))}"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/plant_date" />
    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

data 中定义两个变量,viewModel 就是此前定义的 PlantAndGardenPlantingsViewModel,内部封装了列表项所需数据,click 则是一个点击事件的监听器,负责处理列表项被点击之后跳转到植物详情页面,这个在【2.3】节曾介绍过。

再看适配器的逻辑代码:

public class GardenPlantingAdapter extends ListAdapter<PlantAndGardenPlanting, GardenPlantingAdapter.ViewHolder> {

    public GardenPlantingAdapter() {
        super(new GardenPlantingDiffCallback());
    }

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new ViewHolder(ListItemGardenPlantingBinding.inflate(LayoutInflater.from(parent.getContext()),
                parent, false));
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        PlantAndGardenPlanting plantings = getItem(position);
        holder.itemView.setTag(plantings);
        holder.bind(createOnClickListener(plantings.getPlant().getPlantId()), plantings);
    }

    private View.OnClickListener createOnClickListener(String plantId) {
        return view -> Navigation.findNavController(view).navigate(
                GardenFragmentDirections.actionGardenFragmentToPlantDetailFragment(plantId));
    }


    static class ViewHolder extends RecyclerView.ViewHolder {

        private ListItemGardenPlantingBinding binding;

        public ViewHolder(ListItemGardenPlantingBinding binding) {
            super(binding.getRoot());
            this.binding = binding;
        }


        public void bind(View.OnClickListener onClickListener, PlantAndGardenPlanting gardenPlanting) {
            binding.setClick(onClickListener);
            binding.setViewModel(new PlantAndGardenPlantingsViewModel(gardenPlanting));
            binding.executePendingBindings();
        }
    }

    static class GardenPlantingDiffCallback extends DiffUtil.ItemCallback<PlantAndGardenPlanting> {

        @Override
        public boolean areItemsTheSame(@NonNull PlantAndGardenPlanting oldItem, @NonNull PlantAndGardenPlanting newItem) {
            return oldItem.getPlant().getPlantId().equals(newItem.getPlant().getPlantId());
        }

        @SuppressLint("DiffUtilEquals")
        @Override
        public boolean areContentsTheSame(@NonNull PlantAndGardenPlanting oldItem, @NonNull PlantAndGardenPlanting newItem) {
            return oldItem.equals(newItem);
        }
    }
}

这样我的花园页面基本就完成了,参考代码 b264714 & 7083952。

4、植物介绍页面

点击植物目录中的植物 Item 后会进入植物介绍页面,主要有三个功能:

  1. 植物信息展示,包括植物图片、名称、浇水周期、文字描述等信息
  2. 右下角 + 会将当前植物添加到我的花园中
  3. 右上角分享

4.1 植物详情展示

页面展示内容需要数据支持,所以还是先看数据如何组织。展示页面详情的 PlantDetailViewModel 中需要包含 plantId 以及保存 Plant 的 LiveData:

public class PlantDetailViewModel extends ViewModel {

    private String plantId;
    public LiveData<Plant> plant;
    public LiveData<Boolean> isPlanted;
    private PlantRepository plantRepository;

    public PlantDetailViewModel(PlantRepository plantRepository, String plantId) {
        this.plantRepository = plantRepository;
        this.plantId = plantId;
        plant = plantRepository.getPlant(plantId);
    }
}

PlantDetailViewModel 只通过对应的数据仓库 PlantRepository 获取数据,这个在【2.1】节已经介绍过了,不再赘述。创建 PlantDetailViewModel 的工厂也是跟之前类似的:

public class PlantDetailViewModelFactory extends ViewModelProvider.NewInstanceFactory {

    private PlantRepository plantRepository;
    private String plantId;

    public PlantDetailViewModelFactory(PlantRepository plantRepository, String plantId) {
        this.plantRepository = plantRepository;
        this.plantId = plantId;
    }

    @NonNull
    @Override
    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
        return (T) new PlantDetailViewModel(plantRepository, plantId);
    }
}

数据层基本完成,当然后续在点击按钮向我的花园中添加植物时,数据层还需要改一下,到时候我们再说。接下来看 PlantDetailFragment 的布局:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.frank.demo.sunflower.viewmodels.PlantDetailViewModel" />
    </data>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="@dimen/app_bar_height"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

            <com.google.android.material.appbar.CollapsingToolbarLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_scrollFlags="scroll|exitUntilCollapsed"
                app:title="@{viewModel.plant.name}"
                app:toolbarId="@id/toolbar">

                <ImageView
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:scaleType="centerCrop"
                    app:imageFromUrl="@{viewModel.plant.imageUrl}"
                    app:layout_collapseMode="parallax" />
            </com.google.android.material.appbar.CollapsingToolbarLayout>
        </com.google.android.material.appbar.AppBarLayout>

        <androidx.core.widget.NestedScrollView
            android:id="@+id/plant_detail_scrollview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:clipToPadding="false"
            android:paddingBottom="@dimen/fab_bottom_padding"
            app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent">

                <TextView
                    android:id="@+id/plant_name"
                    style="?android:attr/textAppearanceLarge"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_normal"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:text="@{viewModel.plant.name}"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent"
                    tools:text="Sunflower" />

                <TextView
                    android:id="@+id/plant_watering"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_small"
                    android:layout_marginEnd="@dimen/margin_small"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/plant_name"
                    app:wateringText="@{viewModel.plant.wateringInterval}"
                    tools:text="Watering needs: every 7 days" />

                <TextView
                    android:id="@+id/plant_description"
                    style="?android:attr/textAppearanceMedium"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/margin_small"
                    android:layout_marginTop="@dimen/margin_small"
                    android:layout_marginEnd="@dimen/margin_small"
                    android:textIsSelectable="true"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@+id/plant_watering"
                    app:renderHtml="@{viewModel.plant.description}"
                    tools:text="Details about the plant" />
            </androidx.constraintlayout.widget.ConstraintLayout>
        </androidx.core.widget.NestedScrollView>

        <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/fab"
            style="@style/Widget.Design.FloatingActionButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="@dimen/fab_margin"
            android:tint="@android:color/white"
            app:isGone="@{viewModel.isPlanted}"
            app:layout_anchor="@id/plant_detail_scrollview"
            app:layout_anchorGravity="bottom|end"
            app:srcCompat="@drawable/ic_plus" />
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

这里植物图片、浇水周期、植物描述和 FloatingActionButton 是否显示四个属性是需要先读取属性值之后再通过适配器进行处理的:

public class PlantBindingAdapter {

    @BindingAdapter("imageFromUrl")
    public static void bindImageFromUrl(ImageView view, String imageUrl) {
        if (!TextUtils.isEmpty(imageUrl)) {
            Glide.with(view.getContext())
                    .load(imageUrl)
                    .transition(DrawableTransitionOptions.withCrossFade())
                    .into(view);
        }
    }

    /**
     * 拼接出如下字符串:
     * 浇水指南:每隔 %d 天
     */
    @BindingAdapter("wateringText")
    public static void bindWateringText(TextView textView, int wateringInterval) {
        Resources resources = textView.getContext().getResources();
        // 传了两个 wateringInterval,第一个表示 Quantity 的数量值,第二个是填充字符串的占位符的
        String quantityString = resources.getQuantityString(R.plurals.watering_needs_suffix, wateringInterval, wateringInterval);

        // 得到前缀并设置为粗体
        SpannableStringBuilder builder = new SpannableStringBuilder().append(resources.getString(R.string.watering_needs_prefix));
        builder.setSpan(new StyleSpan(Typeface.BOLD), 0, builder.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);

        // 后缀设置字体并拼接到 builder 中
        int start = builder.length();
        builder.append(" ").append(quantityString);
        builder.setSpan(new StyleSpan(Typeface.ITALIC), start, builder.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        textView.setText(builder);
    }

    @BindingAdapter("renderHtml")
    public static void bindRenderHtml(TextView textView, String description) {
        if (description == null) {
            textView.setText("");
        } else {
            textView.setText(HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT));
            // 激活 TextView 中的链接
            textView.setMovementMethod(LinkMovementMethod.getInstance());
        }
    }

    @BindingAdapter("isGone")
    public static void bindIsGone(FloatingActionButton view, boolean isGone) {
        if (isGone) {
            view.hide();
        } else {
            view.show();
        }
    }
}

最后再看 PlantDetailFragment 的代码:

public class PlantDetailFragment extends Fragment {

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        FragmentPlantDetailBinding binding = FragmentPlantDetailBinding.inflate(inflater, container, false);

        // 读取导航至本页面时传递过来的参数 plantId
        PlantDetailFragmentArgs args = PlantDetailFragmentArgs.fromBundle(requireArguments());
        String plantId = args.getPlantId();

        // 创建 PlantDetailViewModel
        PlantDetailViewModelFactory factory = InjectUtils.providePlantDetailViewModelFactory(requireContext(), plantId);
        PlantDetailViewModel plantDetailViewModel = new ViewModelProvider(this, factory).get(PlantDetailViewModel.class);

        // 绑定生命周期(因为 ViewModel 没有用 observe() 监听)并将 plantDetailViewModel 传给布局中定义的 viewModel 变量
        binding.setLifecycleOwner(this);
        binding.setViewModel(plantDetailViewModel);
        return binding.getRoot();
    }
}

这里有一个接收跳转参数的过程,在接收之前,你需要现在导航图中定义需要接收的参数和类型:

	<fragment
        android:id="@+id/plant_detail_fragment"
        android:name="com.frank.demo.sunflower.fragments.PlantDetailFragment"
        android:label="@string/plant_details_title">

        <argument
            android:name="plantId"
            app:argType="java.lang.String" />
    </fragment>

然后在 PlantListFragment 的 RecyclerView 所关联的 PlantAdapter 中,在绑定数据时定义点击 Item 时的监听器,并将这个监听器对象传给布局文件中的 click 变量:

	@Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Plant plant = getItem(position);
//        holder.itemView.setTag(plant);
        holder.bind(createOnClickListener(plant.getPlantId()), plant);
    }

    private View.OnClickListener createOnClickListener(String plantId) {
        return view -> Navigation.findNavController(view).navigate(
                PlantListFragmentDirections.actionPlantListFragmentToPlantDetailFragment(plantId));

    }

	static class ViewHolder extends RecyclerView.ViewHolder {

        ...

        public void bind(View.OnClickListener listener, Plant plant) {
            // 将 plant 设置给 Item 布局文件中定义的变量
            binding.setPlant(plant);
            // 将监听器传给 click 变量
            binding.setClick(listener);
            binding.executePendingBindings();
        }
    }

这样在植物目录中点击某个植物就可以跳转到该植物的详情页面中了,参考代码 a292595e。

4.2 添加植物至我的花园

在植物详情页面点击 + 按钮会将植物添加到我的花园中,实际上就是向 garden_plantings 表中插入一个新的 GardenPlanting 对象。我们由上至下写这个过程,首先是在 PlantDetailFragment 中给按钮设置监听,点击时通过数据仓库向数据库添加数据:

	@Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        FragmentPlantDetailBinding binding = FragmentPlantDetailBinding.inflate(inflater, container, false);

        ...

        binding.fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                GardenPlantingRepository repository = InjectUtils.getGardenPlantingRepository(requireContext());
                String id = plantDetailViewModel.plant.getValue().getPlantId();
                repository.addPlantToGarden(id);
            }
        });

        return binding.getRoot();
    }

数据仓库和对应的表添加相应方法:

public class GardenPlantingRepository {
	public void addPlantToGarden(String plantId) {
        // 不能放在主线程中
        AppExecutors.getInstance().diskIO().execute(() ->
                gardenPlantingDao.insertGardenPlanting(new GardenPlanting(plantId, null, null)));
    }
}
------------------------------------------------------------------
@Dao
public interface GardenPlantingDao {

    @Insert
    long insertGardenPlanting(@NonNull GardenPlanting gardenPlanting);
}

AppExecutors 是我们自己封装的一个小执行器,将线程分为主线程、IO 线程和执行网络操作的线程:

public class AppExecutors {

    private static final int THREAD_COUNT = 3;

    private Executor diskIO;
    private Executor networkIO;
    private Executor mainThread;

    private AppExecutors(Executor diskIO, Executor networkIO, Executor mainThread) {
        this.diskIO = diskIO;
        this.networkIO = networkIO;
        this.mainThread = mainThread;
    }

    private static class InstanceHolder {
        private static AppExecutors instance = new AppExecutors(
                new DiskIOThreadExecutor(),
                Executors.newFixedThreadPool(THREAD_COUNT),
                new MainThreadExecutor());
    }

    public static AppExecutors getInstance() {
        return InstanceHolder.instance;
    }

    // 主线程任务
    static class MainThreadExecutor implements Executor {

        private Handler mainThreadHandler = new Handler(Looper.getMainLooper());

        @Override
        public void execute(Runnable runnable) {
            mainThreadHandler.post(runnable);
        }
    }

    // 磁盘 IO 任务
    static class DiskIOThreadExecutor implements Executor {

        private final Executor diskIO;

        public DiskIOThreadExecutor() {
            diskIO = Executors.newSingleThreadExecutor();
        }

        @Override
        public void execute(Runnable runnable) {
            diskIO.execute(runnable);
        }
    }
	
	// getters...
}

以上算是完成了向数据库的插入,还需将详情页面的 + 按钮隐藏,这个数据由 PlantDetailViewModel 提供:

public class PlantDetailViewModel extends ViewModel {
	private String plantId;
    public LiveData<Plant> plant;
    public LiveData<Boolean> isPlanted;
    private PlantRepository plantRepository;
    private GardenPlantingRepository gardenPlantingRepository;


    public PlantDetailViewModel(PlantRepository plantRepository, GardenPlantingRepository gardenPlantingRepository, String plantId) {
        this.plantRepository = plantRepository;
        this.gardenPlantingRepository = gardenPlantingRepository;
        this.plantId = plantId;
        plant = plantRepository.getPlant(plantId);

        // 获取指定 plantId 的 GardenPlanting,如果其不为空说明 plantId 所表示的植物被种植了
        LiveData<GardenPlanting> gardenPlantingForPlant = gardenPlantingRepository.getGardenPlantingForPlant(plantId);
        isPlanted = Transformations.map(gardenPlantingForPlant, it -> it != null);
    }
}

isPlanted 在布局文件中被使用到了,配合 app:isGone=“@{viewModel.isPlanted}” 可以让 FloatingActionButton 在该植物已经被种植的情况下不再显示,参考代码 7256aa9。

5、其它功能与问题解决

5.1 导航回退箭头

当前代码在植物目录页面,左上角的导航箭头是一个 ←,点击后会回退到我的花园页面,我们需要它显示成 ☰,点击后显示抽屉菜单,可以在创建 AppBarConfiguration 对象时,将所有需要显示成 ☰ 的页面 id 传给 Builder,而不是直接传导航图资源:

public class GardenActivity extends AppCompatActivity {
	@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ...
        appBarConfiguration = new AppBarConfiguration.Builder(R.id.garden_fragment, R.id.plant_list_fragment)
                .setOpenableLayout(binding.drawerLayout)
                .build();
	}
}

实际上,传入的 id 所对应的 Fragment 会被视为顶级目标,所以左上角才显示菜单而不是回退箭头。代码参考 39e65ed2。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值