本文概述
- 示例应用
- 干净的架构
- 应用技术
- 域层
- 模型
- 介绍
- 依赖注入
- 变化
- 变更1
- 变更2
- 本文总结
在研究了该应用程序的工作方式, 存在的模块以及它们之间如何通信的七个小时之后, 我对该功能进行了一些试用实现。真是地狱数据模型的小变化迫使登录屏幕发生大变化。添加网络请求需要更改几乎所有屏幕和GodOnlyKnowsWhatThisClassDoes类的实现。当将数据保存到数据库中时, 按钮颜色的变化会导致奇怪的行为, 或者导致应用程序完全崩溃。第二天中途, 我告诉我的项目经理:” 我们有两种方法来实现此功能。首先, 我可以再花三天时间, 最后以非常肮脏的方式实现它, 而每个下一个功能或错误修正的实现时间将成倍增长。或者, 我可以重写该应用程序。这将花费我两到三周的时间, 但我们将为将来的应用程序更改节省时间。” ” 很幸运, 他同意了第二种选择。如果我曾经怀疑过为什么一个应用程序(甚至很小的一个)中的良好软件体系结构很重要, 那么这个应用程序会完全消除它们。但是, 我们应该使用哪种Android架构模式来避免此类问题?
在本文中, 我想向你展示一个Android应用程序中干净的架构示例。但是, 这种模式的主要思想可以适应每种平台和语言。好的架构应独立于平台, 语言, 数据库系统, 输入或输出之类的细节。
示例应用我们将创建一个简单的Android应用, 以使用以下功能注册我们的位置:
- 用户可以使用名称创建一个帐户。
- 用户可以编辑帐户名称。
- 用户可以删除该帐户。
- 用户可以选择活动帐户。
- 用户可以保存位置。
- 用户可以查看用户的位置列表。
- 用户可以看到用户列表。
层职责:
- 域:包含我们应用程序的业务规则。它应提供反映我们应用程序功能的用例。
- 演示:向用户展示数据, 并收集必要的数据, 例如用户名。这是一种输入/输出。
- 型号:为我们的应用提供数据。它负责从外部源获取数据并将其保存到数据库, 云服务器等。
相反, 模型层应该知道表示层吗?再说一次-不, 因为如果我们将数据源从数据库更改为网络, 则它不应更改UI中的任何内容(如果你考虑在此处添加加载器, 是的, 但是我们也可以拥有UI加载器使用数据库时)。因此, 这两层是完全分开的。大!
那么域层呢?这是最重要的一个, 因为它包含所有主要的业务逻辑。这是我们要在将数据传递到模型层或呈现给用户之前处理数据的地方。它应该独立于任何其他层-它对数据库, 网络或用户界面一无所知。由于这是核心, 因此其他层将仅与此层通信。为什么我们要拥有完全独立的?业务规则的更改频率可能不如UI设计或数据库或网络存储中的某些更改。我们将通过一些提供的接口与该层进行通信。它不使用任何具体的模型或UI实现。这些都是细节, 请记住-细节会改变。好的架构并不局限于细节。
目前还没有足够的理论。让我们开始编码吧!本文围绕代码展开, 因此, 为了更好地理解, 你应该从GitHub下载代码并检查其中的内容。创建了三个Git标记-architecture_v1, architecture_v2和architecture_v3, 它们与本文的各个部分相对应。
应用技术在应用程序中, 我使用Kotlin和Dagger 2进行依赖项注入。这里既不需要Kotlin也不需要Dagger 2, 但这使事情变得容易得多。我可能没有使用RxJava(也没有RxKotlin), 但你可能对此感到惊讶, 但我发现它在这里没有用, 而且我不喜欢使用任何库, 因为它位于最上层并且有人说这是必须的。正如我所说的那样, 语言和库是细节, 因此你可以使用所需的内容。还使用了一些Android单元测试库:JUnit, Robolectric和Mockito。
域层我们的Android应用程序架构设计中最重要的层是域层。让我们开始吧。这是我们的业务逻辑和与其他层进行通信的接口。主要核心是UseCases, 它反映了用户可以使用我们的应用程序执行的操作。让我们为他们准备一个抽象:
abstract class UseCase<
out Type, in Params>
{private var job: Deferred<
OneOf<
Failure, Type>
>
? = nullabstract suspend fun run(params: Params): OneOf<
Failure, Type>
fun execute(params: Params, onResult: (OneOf<
Failure, Type>
) ->
Unit) {
job?.cancel()
job = async(CommonPool) { run(params) }
launch(UI) {
val result = job!!.await()
onResult(result)
}
}open fun cancel() {
job?.cancel()
}open class NoParams
}
我决定在这里使用Kotlin的协程。每个UseCase必须实现一个run方法来提供数据。在后台线程上调用此方法, 并在接收到结果后将其传递到UI线程上。返回的类型是OneOf < F, T> -我们可以返回错误或数据成功:
sealed class OneOf<
out E, out S>
{
data class Error<
out E>
(val error: E) : OneOf<
E, Nothing>
()
data class Success<
out S>
(val data: S) : OneOf<
Nothing, S>
()val isSuccess get() = this is Success<
S>
val isError get() = this is Error<
E>
fun <
E>
error(error: E) = Error(error)
fun <
S>
success(data: S) = Success(data)fun oneOf(onError: (E) ->
Any, onSuccess: (S) ->
Any): Any =
when (this) {
is Error ->
onError(error)
is Success ->
onSuccess(data)
}
}
域层需要其自己的实体, 因此下一步是定义它们。目前, 我们有两个实体:User和UserLocation:
data class User(var id: Int? = null, val name: String, var isActive: Boolean = false)data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)
现在我们知道要返回什么数据, 我们必须声明数据提供者的接口。这些将是IUsersRepository和ILocationsRepository。它们必须在模型层中实现:
interface IUsersRepository {
fun setActiveUser(userId: Int): OneOf<
Failure, User>
fun getActiveUser(): OneOf<
Failure, User?>
fun createUser(user: User): OneOf<
Failure, User>
fun removeUser(userId: Int): OneOf<
Failure, User?>
fun editUser(user: User): OneOf<
Failure, User>
fun users(): OneOf<
Failure, List<
User>
>
}interface ILocationsRepository {
fun locations(userId: Int): OneOf<
Failure, List<
UserLocation>
>
fun addLocation(location: UserLocation): OneOf<
Failure, UserLocation>
}
这组操作应足以为应用程序提供必要的数据。在此阶段, 我们尚未决定如何存储数据-这是我们希望独立的细节。目前, 我们的域层甚至都不知道它在Android上。我们将尝试保持此状态(排序。稍后我将进行解释)。
最后一步(或几乎最后一步)是为UseCases定义实现, 演示数据将使用这些实现。所有这些都非常简单(就像我们的应用程序和数据一样简单)-它们的操作仅限于从存储库中调用适当的方法, 例如:
class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<
List<
UserLocation>
, UserIdParams>
() {
override suspend fun run(params: UserIdParams): OneOf<
Failure, List<
UserLocation>
>
= repository.locations(params.userId)
}
存储库抽象使我们的UseCases非常易于测试-我们不必关心网络或数据库。它可以以任何方式进行模拟, 因此我们的单元测试将测试实际用例, 而不是其他不相关的类。这将使我们的单元测试变得简单而快速:
@RunWith(MockitoJUnitRunner::class)
class GetLocationsTests {
private lateinit var getLocations: GetLocations
private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1))@Mock
private lateinit var locationsRepository: ILocationsRepository@Before
fun setUp() {
getLocations = GetLocations(locationsRepository)
}@Test
fun `should call getLocations locations`() {
runBlocking { getLocations.run(UserIdParams(1)) }
verify(locationsRepository, times(1)).locations(1)
}@Test
fun `should return locations obtained from locationsRepository`() {
given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations))
val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) }
returnedLocations shouldEqual OneOf.Success(locations)
}
}
目前, 域层已完成。
模型作为Android开发人员, 你可能会选择Room, 这是用于存储数据的新Android库。但是, 让我们想象一下, 项目经理问你是否可以推迟有关数据库的决定, 因为管理层正在尝试在Room, Realm和一些新的超快速存储库之间做出选择。我们需要一些数据才能开始使用UI, 因此我们现在将其保留在内存中:
class MemoryLocationsRepository @Inject constructor(): ILocationsRepository {
private val locations = mutableListOf<
UserLocation>
()override fun locations(userId: Int): OneOf<
Failure, List<
UserLocation>
>
= OneOf.Success(locations.filter { it.userId == userId })override fun addLocation(location: UserLocation): OneOf<
Failure, UserLocation>
{
val addedLocation = location.copy(id = locations.size + 1)
locations.add(addedLocation)
return OneOf.Success(addedLocation)
}
}
介绍两年前, 我写了一篇有关MVP的文章, 它是Android的一个非常好的应用程序结构。当Google发布出色的架构组件(使Android应用程序开发变得更加容易)时, 不再需要MVP, 而可以用MVVM代替。但是, 这种模式中的一些想法仍然非常有用, 例如关于愚蠢的观点的想法。他们应该只关心显示数据。为此, 我们将使用ViewModel和LiveData。
我们的应用程序的设计非常简单-一项带有底部导航的活动, 其中两个菜单项显示了位置片段或用户片段。在这些视图中, 我们使用ViewModels, 而ViewModels又使用域层中的UseCases, 以保持通信的整洁和简单。例如, 这是LocationsViewModel:
class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() {
var locations = MutableLiveData<
List<
UserLocation>
>
()fun loadLocations(userId: Int) {
getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) }
}fun saveLocation(location: UserLocation, onSaved: (UserLocation) ->
Unit) {
saveLocation.execute(UserLocationParams(location)) {
it.oneOf(::handleError) { location ->
handleLocationSave(location, onSaved) }
}
}private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) ->
Unit) {
val currentLocations = locations.value?.toMutableList() ?: mutableListOf()
currentLocations.add(location)
this.locations.value = http://www.srcmini.com/currentLocations
onSaved(location)
}private fun handleLocationsChange(locations: List<
UserLocation>
) {
this.locations.value = locations
}
}
对于不熟悉ViewModels的用户的一些解释-我们的数据存储在location变量中。当我们从getLocations用例获取数据时, 会将它们传递给LiveData值。此更改将通知观察者, 使他们可以做出反应并更新其数据。我们在一个片段中为数据添加观察者:
class LocationsFragment : BaseFragment() {...private fun initLocationsViewModel() {
locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java]
locationsViewModel.locations.observe(this, Observer<
List<
UserLocation>
>
{ showLocations(it ?: emptyList()) })
locationsViewModel.error.observe(this, Observer<
Failure>
{ handleError(it) })
}private fun showLocations(locations: List<
UserLocation>
) {
locationsAdapter.locations = locations
}private fun handleError(error: Failure?) {
toast(R.string.user_fetch_error).show()
}}
每次位置更改时, 我们只需将新数据传递到分配给回收站视图的适配器即可, 这就是在回收站视图中显示数据的常规Android流程的去向。
因为我们在视图中使用ViewModel, 所以它们的行为也很容易测试-我们可以模拟ViewModel, 而不必关心数据源, 网络或其他因素:
@RunWith(RobolectricTestRunner::class)
@Config(application = TestRegistryRobolectricApplication::class)
class LocationsFragmentTests {private var usersViewModel = mock(UsersViewModel::class.java)
private var locationsViewModel = mock(LocationsViewModel::class.java)lateinit var fragment: LocationsFragment@Before
fun setUp() {
UsersViewModelMock.intializeMock(usersViewModel)
LocationsViewModelMock.intializeMock(locationsViewModel)fragment = LocationsFragment()
fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel)
startFragment(fragment)
}@Test
fun `should getActiveUser on start`() {
Mockito.verify(usersViewModel).getActiveUser()
}@Test
fun `should load locations from active user`() {
usersViewModel.activeUserId.value = http://www.srcmini.com/1
Mockito.verify(locationsViewModel).loadLocations(1)
}@Test
fun `should display locations`() {
val date = Date(1362919080000)//10-03-2013 13:38locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1))val recyclerView = fragment.find<
RecyclerView>
(R.id.locationsRecyclerView)
recyclerView.measure(100, 100)
recyclerView.layout(0, 0, 100, 100)
val adapter = recyclerView.adapter as LocationsListAdapter
adapter.itemCount `should be` 1
val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder
viewHolder.latitude.text `should equal`"Lat: 1.0"
viewHolder.longitude.text `should equal` "Lng: 2.0"
viewHolder.locationDate.text `should equal` "10-03-2013 13:38"
}
}
你可能会注意到, 表示层也被分成带有清晰边框的较小层。活动, 片段, ViewHolders等视图仅负责显示数据。他们只知道ViewModel层, 并且仅使用它来获取或发送用户和位置。它是一个与域通信的ViewModel。视图的ViewModel实现与域的UseCases相同。简而言之, 干净的架构就像洋葱一样-它具有层次, 层次也可以具有层次。
依赖注入我们已经为我们的体系结构创建了所有类, 但是还有另一件事要做-我们需要将所有东西连接在一起的东西。表示层, 领域层和模型层保持整洁, 但我们需要一个模块, 该模块将是肮脏的, 并且将了解所有内容, 通过此知识, 它将能够连接我们的层。做到这一点的最佳方法是使用一种常见的设计模式(SOLID中定义的一种简洁代码原则)—依赖注入, 它会为我们创建合适的对象并将它们注入所需的依赖关系。我在这里使用了Dagger 2(在项目的中间, 我将版本更改为2.16, 它的样板更少), 但是你可以使用任何喜欢的机制。最近, 我在Koin库中玩了一点, 我认为也值得一试。我想在这里使用它, 但是在测试时模拟ViewModel时遇到了很多问题。我希望我能找到一种快速解决它们的方法-如果是这样, 在使用Koin和Dagger 2时, 我可以为这个应用程序展示差异。
你可以使用标记architecture_v1在GitHub上检查此阶段的应用程序。
变化我们完成了层, 测试了应用程序-一切正常!除了一件事之外, 我们仍然需要知道PM要使用哪个数据库。假设他们来找你, 并说管理部门同意使用Room, 但是他们仍然希望将来有可能使用最新的超快速图书馆, 因此你需要牢记潜在的变化。此外, 一位涉众询问了是否可以将数据存储在云中, 并想知道这种更改的成本。因此, 这是检查我们的体系结构是否良好以及是否可以在不对表示层或域层进行任何更改的情况下更改数据存储系统的时候。
变更1使用Room时, 第一件事是为数据库定义实体。我们已经有一些:User和UserLocation。我们要做的就是添加诸如@Entity和@PrimaryKey之类的注释, 然后可以在我们的模型层中将其与数据库一起使用。大!这是打破我们想要保留的所有架构规则的绝佳方法。实际上, 无法以这种方式将域实体转换为数据库实体。试想一下, 我们也想从网络下载数据。我们可以使用更多的类来处理网络响应-转换我们的简单实体, 使其与数据库和网络一起使用。这是通往未来灾难的最短路径(并且哭着说:” 谁死了写了这段代码?” )。对于我们使用的每种数据存储类型, 我们都需要单独的实体类。它的成本不高, 因此请正确定义Room实体:
@Entity
data class UserEntity(
@PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false
)@Entity(foreignKeys = [
ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE)
])
data class UserLocationEntity(
@PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long
)
如你所见, 它们与域实体几乎相同, 因此存在很大的合并诱惑。这仅是一次意外, 数据越复杂, 相似度就越小。
接下来, 我们必须实现UserDAO和UserLocationsDAO, 我们的AppDatabase, 最后是IUsersRepository和ILocationsRepository的实现。这里有一个小问题-ILocationsRepository应该返回一个UserLocation, 但是它从数据库中接收到一个UserLocationEntity。与用户相关的类也是如此。相反, 当数据库需要UserLocationEntity时, 我们传递UserLocation。为了解决这个问题, 我们需要在域和数据实体之间建立映射器。我使用了我最喜欢的Kotlin功能之一-扩展。我创建了一个名为Mapper.kt的文件, 并在其中放置了所有用于类之间映射的方法(当然, 它在模型层中-域不需要它):
fun User.toEntity() = UserEntity(id?.toLong(), name, isActive)
fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive)
fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong())
fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())
我之前提到的小谎言是关于域实体的。我写过他们对Android一无所知, 但这并非完全正确。我在用户实体中添加了@Parcelize批注, 并在此处扩展了Parcelable, 从而可以将实体传递给片段。对于更复杂的结构, 我们应该提供视图层自己的数据类, 并在域和数据模型之间创建映射器。我敢冒险将Parcelable添加到域实体中-我知道, 在用户实体发生任何更改的情况下, 我将为表示创建单独的数据类, 并从域层中删除Parcelable。
最后要做的是更改我们的依赖项注入模块, 以提供新创建的Repository实现, 而不是先前的MemoryRepository。构建并运行该应用程序后, 我们可以转到PM来显示具有Room数据库的正在运行的应用程序。我们还可以通知PM, 添加网络不会花费太多时间, 并且我们对任何管理层想要的存储库都是开放的。你可以检查哪些文件已更改-仅更改模型层中的文件。我们的架构真的很整洁!可以通过扩展我们的存储库并提供适当的实现, 以相同的方式构建每个下一个存储类型。当然, 事实证明, 我们需要多个数据源, 例如数据库和网络。然后怎样呢?没什么, 我们只需要创建三个存储库实现即可—一个用于网络, 一个用于数据库, 以及一个主要的库, 其中将选择正确的数据源(例如, 如果我们有一个网络, 则从数据库中加载)。网络, 如果没有, 则从数据库加载)。
你可以在GitHub上使用标签architecture_v2签出此阶段的应用程序。
因此, 这一天快要结束了–你坐在电脑前喝杯咖啡, 可以将应用程序发送到Google Play, 当项目经理突然来找你, 并问” 你是否可以添加一项功能, 可以从GPS保存用户的当前位置?”
变更2一切都会发生变化……尤其是软件。这就是为什么我们需要干净的代码和干净的体系结构。但是, 如果我们不加考虑地进行编码, 那么即使最干净的东西也可能很脏。实施从GPS获取位置的第一个想法是在活动中添加所有可识别位置的代码, 在我们的SaveLocationDialogFragment中运行它, 并使用相应的数据创建一个新的UserLocation。这可能是最快的方法。但是, 如果我们疯狂的PM来找我们并要求我们将位置从GPS更改为其他提供商(例如, 蓝牙或网络之类的东西)怎么办?更改将很快失去控制。我们如何以一种干净的方式做到这一点?
用户位置是数据。获取位置是一个用例, 因此我认为我们的域和模型层也应包含在这里。因此, 我们还有一个要实现的UseCase — GetCurrentLocation。我们还需要一个可以为我们提供位置的东西-ILocationProvider接口, 以使UseCase不受GPS传感器等细节的影响:
interface ILocationProvider {
fun getLocation(): OneOf<
Failure, SimpleLocation>
fun cancel()
}class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<
SimpleLocation, UseCase.NoParams>
() {
override suspend fun run(params: NoParams): OneOf<
Failure, SimpleLocation>
=
locationProvider.getLocation()override fun cancel() {
super.cancel()
locationProvider.cancel()
}
}
你可以看到这里还有另外一种方法-取消。这是因为我们需要一种取消GPS位置更新的方法。在模型层中定义的我们的Provider实现在此处进行:
class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider {private var locationManager: LocationManager? = null
private var locationListener: GPSLocationListener? = nulloverride fun getLocation(): OneOf<
Failure, SimpleLocation>
= runBlocking {
val grantedResult = getLocationPermissions()if (grantedResult.isError) {
val error = (grantedResult as OneOf.Error<
Failure>
).error
OneOf.Error(error)
} else {
getLocationFromGPS()
}
}private suspend fun getLocationPermissions(): OneOf<
Failure, Boolean>
= suspendCoroutine {
Dexter.withActivity(activity)
.withPermission(Manifest.permission.ACCESS_FINE_LOCATION)
.withListener(PermissionsCallback(it))
.check()
}private suspend fun getLocationFromGPS(): OneOf<
Failure, SimpleLocation>
= suspendCoroutine {
locationListener?.unsubscribe()
locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager
locationManager?.let { manager ->
locationListener = GPSLocationListener(manager, it)
launch(UI) {
manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener)
}
}
}override fun cancel() {
locationListener?.unsubscribe()
locationListener = null
locationManager = null
}
}
该提供程序准备与Kotlin协程一起工作。如果你还记得, UseCases的run方法是在后台线程上调用的-因此我们必须确保正确标记我们的线程。如你所见, 我们必须在此处传递一个活动-当我们不再需要更新以免发生内存泄漏时, 取消更新并从侦听器注销非常重要。由于它实现了ILocationProvider, 因此我们将来可以轻松地将其修改为其他某个提供程序。即使没有在手机中启用GPS, 我们也可以轻松地测试当前位置(自动或手动)的处理情况, 我们要做的就是替换实现以返回随机构建的位置。为了使其工作, 我们必须将新创建的UseCase添加到LocationsViewModel。反过来, ViewModel必须具有一个新方法getCurrentLocation, 该方法实际上将调用该用例。只需进行一些小的UI更改即可调用它, 并在Dagger中注册GPSProvider-瞧, 我们的应用程序完成了!
本文总结【了解Android干净架构的好处】我试图向你展示如何开发易于维护, 测试和更改的Android应用。它也应该易于理解-如果你是新来的人, 那么他们在理解数据流或结构上就不会有问题。如果他们知道架构是干净的, 则可以确定UI的更改不会影响模型中的任何内容, 并且添加新功能的花费不会超出预期。但这还没有结束。即使我们有一个结构良好的应用程序, 也很容易通过凌乱的代码更改” 仅需片刻, 就可以使用” 来破坏它。请记住, 没有代码” 仅此而已” 。每个违反我们规则的代码都可以保留在代码库中, 并且可以成为将来更大中断的来源。如果你仅在一周后使用该代码, 就好像有人在该代码中实现了一些强大的依赖关系一样, 要解决该问题, 你必须深入研究该应用程序的许多其他部分。好的代码架构不仅在项目开始时就是一个挑战, 对于Android应用程序生命周期的任何部分都是一个挑战。每当事情要改变时, 都要考虑和检查代码。要记住这一点, 例如, 你可以打印并挂起你的Android体系结构图。你还可以通过将层划分为三个Gradle模块来稍微强行实现独立性, 在这些Gradle模块中, 域模块不了解其他模块, 而表示模块和模型模块则互不使用。但是, 这甚至不能取代人们对应用程序代码混乱一无所知的报应。
推荐阅读
- 如何制作Discord机器人(概述和教程)
- logback:fileAppender输出到文件
- 修改 Android Studio 模拟器的默认安装位置
- 7 zabbix主动被动trapper模式
- Android : SeekBar 实现图片旋转缩放
- SharePoint Online 开发篇(SharePoint Hosted Apps获取用户ID)
- 7.2 hadoop失败(任务失败application master 失败节点管理器失败资源管理器失败)
- SharePoint Online 开发篇(App Part替代Content web part)
- Android实战项目(房贷计算器)