该系列文章是对Android推出的架构组件相关文章,按作者自己理解来翻译的,同时标记有作者自己一些简单笔记。如果读者发现文中有翻译不准确的地方,或者理解错误的地方,请不吝指教。

源自Android官方Guide to app architecturel principles 一文的翻译与归纳

其他相关链接:

框架组件使用指导

Android Jetpack Components

[TOC]

前言

移动设备资源受限,在任何时候,系统都有可能回收某些应用进程,为新的进程腾出空间。因此你的应用组件随时可能被系统或用户中断,或者在用户重新打开应用时从某个中间点再次启动。

这些事件不在你的控制之下,因此你不应该在应用程序的组件中存储任何应用程序数据或状态,并且这些应用组件之间不应相互依赖。

==Note:应用被回收从中间点恢复,需要将这些状态数据集中管理。而不是直接保存到Activity或者Fragment中,这将导致你的界面数据很难恢复,回收之后再打开出现异常或者不得不从头开始。==

如果不通过在应用组件中保存数据或状态,那应该如何设计框架呢?

公共框架原则

关注点分离

最重要的原则就是分离关注点,简单来说就是让各个组件各自负责自己应该处理的逻辑,不应该将所有代码都写到Activity或者Fragment中。这些UI相关的类应该只处理UI或者交互相关的逻辑,保持这些类的精简可以更好地避免生命周期相关的问题。

==Note:简化UI类逻辑可以使数据、异步等处理逻辑在各个生命周期体现得更清晰==

请记住,你并没有实现Activity或Fragment,它们只是你和Android操作系统沟通使用的类。系统将用户交互通过这些类来传达给开发者,也可以在低内存等条件下随时销毁他们。所以为了更好地管理你的程序,提升用户体验,需要减少对它们的依赖。

==Note:我们应该把这些类当做是系统提供的接口,而不是要去实现它。我们应该把自己的业务逻辑分离出来,利用这些接口与用户交互。==

从模型中驱动UI

另一个重要的原则是通过模型来驱动你的UI,最好是持久模型。模型是负责处理应用数据的组件,他们独立于应用中的View对象和应用组件(四大组件和其它组件),所以他们不应该受应用程序生命周期和相关问题的影响。

==Note:模型层本身是数据处理中心,持久化的模型层是不受应用组件影响的。应该将Model层分离出来,通过ViewModel驱动UI组件与用户产生交互。==

数据能持久化是最理想的,原因如下:

如果Android系统销毁你的应用以释放资源,用户不会丢失数据。

如果网络连接不稳定或无法使用,你的应用仍可继续使用。

将模型类按数据管理职责明确划分,可以让代码测试、阅读更加轻松。

推荐的应用框架

在本节中,将演示如何使用Architecture Components来构建端到端的应用程序。

设计一个架构适合每种场景的应用程序是不可能的,但是我们推荐的架构适用于大多数情况和工作流程。如果你已经有一种很好地符合公用框架原则的结构,那么你不需要改变他。

想象一下,我们正在构建一个显示用户配置的界面,通过服务器私有API来获取配置数据。

概览

首先,请思考下图,下图显示了应用程序各个模块相互应该如何交互。

概览图

注意,每一级组件仅依赖于下一级组件。举个例子,Activity与Fragment仅依赖ViewModel层,Repository 依赖于持久模型和远端后台数据源。

==Note:在该设计结构中,分层和层次间的依赖关系是关键,每个层级不应该出现越级、交叉之类的依赖关系。同时需要注意ViewModel并不是Model,他是Model层与View层的中间层,类似在MVP中Presenter的角色,与Repository的通信的数据处理业务逻辑应该放到这里。==

这种设计能带来一致和愉悦的用户体验。无论用户在关闭应用几分钟后还是几天后再回到应用程序,都可以立即看到应用程序在本地持久化的数据。如果数据已经过期,Repository会在后台更新数据,并在更新完成后呈现给用户。

构建用户接口

UI 由 UserProfileFragment 和他的布局文件 user_profile_layout.xml 组成。

为了驱动UI,我们的数据模型需要包含以下元素:

User ID: 用户的唯一标识,最好通过fragment的setArguments方法来传递该参数。如果系统销毁我们的进程,这些信息会被保留,因此ID将在下次重新启动时再次使用。

User Object:包含用户详细信息的数据类。

我们使用基于 ViewModel 框架组件的 UserProfileViewModel 类来保存这些信息。

一个ViewModel对象只给特定的activity或fragment提供数据,并包含model通信和数据处理逻辑。

举个例子,ViewModel可以通知其他组件加载数据,也可以根据用户请求来修改数据。

ViewModel不需要了解UI组件,因此它不受配置更改的影响,例如在旋转屏幕后重建activity。

==Note: ViewModel不只是UI组件的一个对象,即使UI组件在某些情况下销毁重建,ViewModel也不一定会丢失,可以被重建后的UI组件继续使用。同时由于持久化的特性,即使ViewModel回收重建,也可以从Model中重新加载数据。==

目前为止我们定义了以下文件:

user_profile_layout.xml: 界面的布局文件

UserProfileFragment: 控制UI显示数据的组件

UserProfileViewModel:为UserProfileFragment准备需要显示的数据以及对用户交互做出反应的类

以下代码片段显示了这些类的初步内容。(省略布局文件代码)

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);

}

}

现在我们有了这些代码模块,我们应该如何关联他们?毕竟,当UserProfileViewModel设置user对象时,我们需要一种方式通知UI更新。而这正是LiveData组件的作用。

LiveData是一个可观察数据的持有者,应用程序中的其他组件可以使用LiveData观察数据改变,而不需要在他们之间创建明确且严格的依赖关系。LiveData组件还遵循activity、fragment和service这些组件的生命周期,且已经包含清理逻辑,以防止内存泄露和过多的内存占用。

将LiveData引入到我们的应用程序中,我们将UserProfileViewModel中的user字段类型改为LiveData。现在,数据更新时可以通知到UserProfileFragment了。此外,由于LiveData可以识别组件生命周期,当不再需要它其中存放的数据后会自动清除组件的引用。

==Note:这里查看LiveData源码,LiveData可以识别Activity、Fragment、Service等组件的生命周期,当生命周期为Destroy时,会自动将该Observer移除。==

UserProfileViewModel

public class UserProfileViewModel extends ViewModel {

...

private LiveData user;

public LiveData getUser() {

return user;

}

}

现在我们修改UserProfileFragment以观察数据改变并更新UI:

UserProfileFragment

@Override

public void onActivityCreated(@Nullable Bundle savedInstanceState) {

super.onActivityCreated(savedInstanceState);

viewModel.getUser().observe(this, new Observer() {

@Override

public void onChanged(User user) {

// 更新UI

}

});

}

每当用户配置数据更新,onChanged()都会被调用,同时会更新UI。

如果你对observable相关库比较熟悉的话,你可能会注意到我们没有在fragment的onStop()里停止数据观察。因为LiveData可以根据生命周期自己处理,所以这一步是没有必要的。也就是说只会在fragment处于活跃状态时(已经onStart()但没有onStop()),才会回调onChanged()方法;LiveData也会在fragment的onDestroy()方法调用后自动移除观察者的引用。

我们也不需要添加任何处理configuration改变的逻辑,比如用户旋转屏幕。UserProfileViewModel会在configuration改变后自动回复,并立即使用当前数据调用回调。鉴于ViewModel的存活时间比他们更新的View更长,在实现ViewModel时不应该包含对View对象的直接引用。需要获取更多关于ViewModel生命周期的信息,可以参考 The lifecycle of a ViewModel。

==Note1:关于上面的描述,查看源码后了解到:FragmentActivity和Fragment在新版本support包里都增加了一个ViewModelStore对象,该对象负责保存这个View创建的所有ViewModel,并且在销毁的使用统一清理。值得一提的是,当activity的onDestroy()方法调用,但是mRetaining为true时,ViewModelStore不会清理,所以重建后依然可以正常使用。==

==Note2:文中提到ViewModel的存活时间比他们更新的View更长,是因为如果activity重建,没有特殊配置的情况下会重新生成一个新的Activity对象,而新的Activity对象使用的依然是重建前的ViewModelStore。如果ViewModel引用了之前的activity,就会产生内存泄露,fragment是一样的原理。另外fragment的ViewModelStore是Activity利用FragmentManager来恢复的。==

获取数据

现在我们已经使用LiveData将UserProfileViewModel连接到UserProfileFragment,下一步我们思考应该怎样从服务端获取数据。

我们假设服务端提供了一个REST API。使用 Retrofit 与后端通信,当然你也可以用使用其它的库来实现。

这里定义WebService与后端通信

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 getUser(@Path("user") String userId);

}

我们首先会想到使用ViewModel直接调用WebService获取数据,并将数据分配给LiveData对象。这个逻辑是可行的,但是如果使用这个逻辑,随着功能增多程序会越来越难维护。这会让UserProfileViewModel做太多工作,违反了关注点分离这一原则。另外,ViewModel的生命周期与Activity或Fragment相关联,也就是说如果UI组件生命周期结束,通过Webservice获取的数据将会丢失。这会让产生不好的用户体验。

所以,我们的ViewModel将获取数据的过程交给Repository模块来完成

Repository 模块负责处理数据相关操作。他们提供一些简洁的API以便其他模块能轻松获取这些数据。他们知道从哪里获取数据以及在什么时候更新数据。你可以认为Repository是不同数据源(持久模型、Web服务和缓存)之前的调解器.

==Note:进一步定义ViewModel的职责,ViewModel本身也不应该关心数据来源,这些逻辑应该交给Repository完成。ViewModel只是简单加工数据并通知View更新,以及处理View层产生的用户交互行为。Repository负责数据获取和本地持久化接口调用,保证之后ViewModel获取数据可以很快获取到缓存数据。这样的结构也可以解决我们应用里每次进入都需要重新获取数据才能正常使用的硬伤。==

我们的UserRepository类(如以下代码所示),使用WebService实例来获取用户的数据。

UserRepository

public class UserRepository {

private Webservice webservice;

// ...

public LiveData getUser(int userId) {

// This isn't an optimal implementation. We'll fix it later.

final MutableLiveData data = new MutableLiveData<>();

webservice.getUser(userId).enqueue(new Callback() {

@Override

public void onResponse(Call call, Response response) {

data.setValue(response.body());

}

// Error case is left out for brevity.

});

return data;

}

}

上面代码看起来repository模块不是必要的,但他有一个重要的目的:作为app其他模块的数据源。现在,我们的UserProfileViewModel不知道怎么获取数据,我们可以为它提供几个不同的数据获取方式。

管理组件之间的依赖

在UserRepository获取用户数据前,需要一个WebService的实例。当然我们可以简单的创建实例,但如果这样做,还需要知道WebService的依赖关系(创建实例需要传参或者初始化)。另外,UserRepository可能并不是唯一需要使用WebService的类。这样的需求会造成重复的代码,因为每个使用WebService的类都需要知道它的依赖关系。如果每个类都创建一个WebService,我们的应用可能变得非常臃肿。

你可以使用以下设计模式来解决此问题:

Dependency injection(依赖注入):依赖注入允许类在不构造它们的情况下定义它们的依赖关系。在运行时,另外的类负责提供这些依赖项。我们推荐Dragger2来实现Android应用的依赖注入。Dragger2通过遍历依赖树自动构造对象,并为依赖关系提供编译时保证。

Service locator(服务定位器):服务定位器模式提供一个注册表,其中的类可以获取它们的依赖而不用构造它们。

实现服务注册表比使用依赖注入更简单,如果你不熟悉依赖注入,可以考虑使用服务定位器模式。

这些设计模式提供了清晰的模式管理依赖项,你无须复制代码或增加复杂性就能扩展代码功能。此外,这些模式允许你能在获取测试和生产数据的之间快速切换。

我们的示例使用 Dragger 2 来管理WebService对象的依赖。

连接 ViewModel 与 Repository

现在,我们增加UserProfileViewModel使用UserRepository对象的代码:

UserProfileViewModel

public class UserProfileViewModel extends ViewModel {

private LiveData 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 getUser() {

return this.user;

}

}

缓存数据

UserRepository将对WebService的调用抽象出来,但是他不是很灵活(通用),因为他只依靠一个数据源。

UserRespository的关键问题在于他总是从后端获取数据,并没有将数据存储下来。因此如果用户离开UserProfileFragment,再返回来时必须重新获取数据,即使数据没有发生改变。

下面是该设计并不理想的原因:

它浪费了多余的网络带宽

他强制要求用户等待查询完成

为了解决这些问题,我们在UserRepository中添加一个新的数据源,用于在内存中缓存User对象:

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 getUser(int userId) {

LiveData cached = userCache.get(userId);

if (cached != null) {

return cached;

}

final MutableLiveData 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() {

@Override

public void onResponse(Call call, Response response) {

data.setValue(response.body());

}

});

return data;

}

}

持久化数据

根据我们当前的实现,由于Repository能够从内存中查找数据,如果用户旋转屏幕,或者离开后立即回到App,界面都能立即显示。

然而,如果用户离开几个小时后,Android系统杀掉应用进程,用户再打开应用,会发生什么呢?这种情形下根据我们当前的实现,需要重新连接网络获取数据。这种重新获取的过程不仅仅是糟糕的用户体验,也会浪费宝贵的移动数据流量。

==Note:根据数据的实时性和体量,将数据量大、实时性小的数据做持久化缓存。而通过少了数据标识来决定是否对持久化数据更新,已达到数据流量的最大利用,也能用户重启应用带来良好体验。==

你可以通过缓存Web请求来解决这个问题,但是这会产生另一个问题:如果相同的用户数据也可以来自其他类型的请求,比如请求好友列表,两份数据获取时间不一致,导致两份数据相同部分可能不一致。举个例子,如果用户在不同时间发出好友列表请求和单个用户请求,我们的应用可能会显示同一用户数据的两个版本。我们的应用需要弄清楚如何合并这些不一致的数据。

==Note:这也是一个常见问题,比如列表数据获取后,点击列表内进入详情页面获取详情数据,但是详情数据相对列表数据已经发生改变,就会造成里外不一致的情况。==

处理这种情况最好的方式是使用持久模型,这就是Room来拯救的地方

Room是一个对象映射库,提供本地数据持久化,并且只有很小的代码体积。在编译时,它根据你创建的每个数据模型验证每个查询,因此错误的SQL查询会导致编译错误,而不是运行时失败。Room封装了使用原始SQL查询和一些底层实现细节。他也允许你观察数据库数据的改变,包括集合查询和多表查询,通过LiveData对象来通知这些改变。他甚至明确定义了执行约束来解决常见的线程问题,例如在主线程访问storage。

要使用Room,我们需要定义本地模型。首先,我们添加@Entity注解到User模型类,同时为id字段添加@PrimaryKey。这些注解让User像数据库中的一张表,而id则是表的主键。

User

@Entity

class User {

@PrimaryKey

private int id;

private String name;

private String lastName;

// Getters and setters for fields.

}

然后,我们实现RoomDatabase类来创建数据库:

UserDatabase

@Database(entities = {User.class}, version = 1)

public abstract class UserDatabase extends RoomDatabase {

}

注意,UserDatabase是抽象的,Room会自动提供它的实例,更多细节请参考Room文档

我们现在需要一种方式将用户数据插入数据库。为此我们创建一个数据访问对象(DAO)。

UserDao

@Dao

public interface UserDao {

@Insert(onConflict = REPLACE)

void save(User user);

@Query("SELECT * FROM user WHERE id = :userId")

LiveData load(int userId);

}

注意load方法返回了一个LiveData对象。Room知道数据库何时被修改,并在数据改变时自动通知所有活跃状态的观察者。因为Room使用了LiveData,因此该操作非常有效,它仅在至少有一个活动观察者时才更新数据。

==Note:LiveData在分发数据改变事件时,会判断观察者是否处于活跃状态,如果不是则不会处理。==

在定义UserDao类之后,我们从数据库类中引用DAO

UserDatebase

@Database(entities = {User.class}, version = 1)

public abstract class UserDatabase extends RoomDatabase {

public abstract UserDao userDao();

}

现在,我们可以修改UserRepository来引入本地数据源。

UserRepository

@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 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 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());

}

});

}

}

请注意,即使我们在UserRepository中改变的数据来源,也不需要改变UserProfileViewModel或者UserProfileFragment。这个小范围的更新展示了我们应用框架提供的灵活性。也更有利于测试,因为我们可以提供一个模拟的UserRepository来测试UserProfileViewModel和其它组件。

使用这种结构,如果用户等待几天后再次打开应用,在repository更新数据之前,他们可以先看到本地过期的数据。当然,你可以能并不希望展示这些过期信息,你可以先展示一些占位数据并提示你的应用正在加载最新信息。

统一的数据源

不同的REST API接口通常会返还相同的数据。比如,如果我们的后台有另一个接口返回好友列表,那么相同一个用户对象可能来自两个接口,甚至可能使用不同级别的粒度(用户接口获取的信息比列表中的用户对象更详细)。如果UserRepository按原样从WebService请求数据,而不检查一致性,我们的UI可能会展示不一样的数据,因为最近调用的接口决定了repository的数据版本和格式。

由于这个原因,我们的UserRepository实现将Web服务返回的数据保存到数据库中,而改变数据库会触发LiveData的回调。通过该模型,数据库提供统一的数据源,应用的其他部分使用UserRepository访问该数据源。无论是否使用disk cache,我们都建议你的repository将数据源统一成唯一的实际源头。

显示加载中

在一些用例中,比如下拉刷新,向用户展示正在进行网络操作是非常重要的。将UI操作与实际数据分离是一种很好的做法,因为数据可能会因为各种原因而更新。举个例子,假设我们获取一个好友列表,同一个用户信息可能会以编程的方式再次获取,从而触发LiveData的刷新。从UI的角度来看,这次请求只是另一个数据获取点,类似于获取User对象本身。

我们可以使用以下策略来保证在UI中显示一致的数据更新指示,而不用管更新数据的请求来自何处:

修改getUser()方法返回LiveData对象的类型,这个对象可能包含一个网络操作状态。

可以参考android-architecture-components中的NetworkBoundResource的实现。

在UserRepository中提供另一个可以返回刷新状态的方法。如果数据只会通过用户操作更新,那么这种方式会更好。

测试每个组件

这一段没翻译,大家可以自己看看原文

最佳实践

编程是一个创造性领域,构建Android应用也不例外。无论是在各个界面传递数据,或是获取后端数据并在本地持久化,还是一些其它重要常见,都有许多方法可以解决问题。

虽然以下建议都不是强制性的,但根据我们的经验来看,遵循他们可以使您的代码库在长期运行中更加可靠、健壮、可维护。

避免将你的应用入口点作为数据源(例如activity、service和boardcast)

在应用各个模块之间明确定义责任范围

每个模块尽可能少地暴露,降低使用的学习成本

考虑如果让每个模块可以单独测试

专注于你的应用的核心,从其它应用中脱颖而出

尽可能保证数据的及时性和相关性

创建单一数据源以保证数据的统一性和有效性

android各组件翻译,Android App框架指南(译文)相关推荐

  1. android置组件下面,Android Jetpack架构组件(十二)之Hilt

    一. 依赖注入简介 依赖注入(英文Dependency Injection,简写DI)是一种被广泛使用的编程技术,主要的作用代码解耦. 借助依赖注入,我们可以轻松的管理类之间的依赖,并最终建立高可维护 ...

  2. android 毕业设计 文献翻译,android毕业设计外文翻译.doc

    android毕业设计外文翻译 大连东软信息学院 毕业设计(论文)外文资料及译文 系 所:电子工程系 专 业嵌入式系统工程 班 级:嵌入式11101 姓名郑立敏 学 号:09160310322 大连东 ...

  3. android气泡组件,气泡  |  Android 开发者  |  Android Developers

    气泡让用户可以轻松查看并参与对话. 气泡内置于"通知"系统中.它们浮动在其他应用内容上层,并会跟随用户转到任意位置.气泡可以展开以显示应用功能和信息,并可在不使用时收起. 当设备处 ...

  4. android自定义组件属性,android自定义控件并添加属性的方法以及示例

    安卓系统为我们提供了丰富的控件,但是在实际项目中我们仍然需要重新通过布局来实现一些效果,比如我们需要一个上面图标,下面文字的button,类似于下面这样的: 最直接的解决办法是通过将imageview ...

  5. android使组件居中,Android图文居中显示控件使用方法详解

    最近项目中用到了文字图标的按钮,需要居中显示,如果用TextView实现的方式,必须同时设置padding和drawablePadding.如下: android:layout_width=" ...

  6. android自定义组件属性,Android组合控件详解 自定义属性

    组合控件详解 & 自定义属性 组合控件是自定义控件的一种,只不过它是由其他几个原生控件组合而成,故名组合控件. 在实际项目中,GUI 会遇到一些可以提取出来做成自定义控件情况. 一个自定义控件 ...

  7. android 蘑菇街组件化,蘑菇街 App 的组件化之路

    编辑推荐: 本文来自于csdn,文章主要分享了casatwy 的一些思路和思考问题的角度,希望对您的学习有帮助. 统一的调用实现 将「URL 调用」和「组件间调用」通过 runtime 达到统一,通过 ...

  8. android 登录组件开发,Android组件化开发路由的设计

    调研了一下目前的路由框架,ARouter(阿里的),ActivityRouter都使用了apt技术 编译时注解,个人想法是一口吃不成胖子,先做个比较实用的. VpRouter路由框架主要应用于组件化开 ...

  9. android四大组件 服务,Android四大组件之Service

    Service Service(服务)是一个可以在后台执行长时间运行操作而不使用用户界面的应用组件.服务可由其他应用组件启动,而且即使用户切换到其他应用,服务仍将在后台继续运行. 此外,组件可以绑定到 ...

最新文章

  1. activexobject java_JS进阶篇--IE浏览器的ActiveXObject对象以及FileSystemobject的应用扩展...
  2. 在便宜、快速和可靠中三选二
  3. 更改数据库表中有数据的字段类型NUMERIC(18,2)为NUMERIC(18,6)
  4. Poj(2225),三维BFS
  5. 如果同时需要两张表,但其中一个表中没有另一个表中的字段,该如何正确使用
  6. 【泛微E9开发】E9客户端下载页面修改方法
  7. LeetCode 714. 买卖股票的最佳时机含手续费
  8. 解决space-evenly在部分浏览器不兼容的问题
  9. java游戏 超级酒吧女生,酒吧游戏你知道多少?22个游戏你玩过几个?
  10. Web——P2P应用
  11. 《Eolink 征文活动- -RESTful接口全解测试-全方位了解Eolink-三神技超亮点》
  12. php打印10以内减法表,10以内加减法口诀表练习题口算题可打印(附下载)
  13. 朗强:HDMI视频画面分割器基本工作原理和性能
  14. matlab 包含nan的行,matlab中去除含有NaN的行或者列
  15. ​ 数据库约束【mysql】
  16. 超详细EVE-NG安装教程,问题解决,关联CRT和Wireshark(适合新手,内含下载地址)
  17. vue 页面跳转404_出现404页面怎么办?应该如何处理404页面?
  18. 第八篇 uCGUI的移植
  19. 微信小程序项目上传到git仓库
  20. 电机驱动与运动控制——复习

热门文章

  1. Eclipse Console 加大显示的行数,禁止弹出
  2. Hivesql里的limit使用误区
  3. 第三方类库的学习心态
  4. python编的游戏越玩越卡_用Python写游戏,不到十分钟就学会了
  5. linux系统的安全机制有哪些内容,系统安全机制
  6. [转载] JAVA面向对象之代码块 继承 方法的重写 super关键字与重写toString()方法介绍
  7. job每分钟执行 oracle_Oracle Job 每个时间点执行示例
  8. python的format函数如何理解_python format函数的使用
  9. 我想成为计算机专业第一,我对计算机专业学生的忠告。
  10. 实战:布隆过滤器安装与使用及原理分析