出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践MAD 的全称是 Modern Android Development , 它是一系列技术栈和工具链的集合,涵盖了从编程语言到开发框架等各个环节

大家好,欢迎来到IT知识分享网。

What is MAD?

出海移动应用的 MAD 最佳实践

MAD 的全称是 Modern Android Development , 它是一系列技术栈和工具链的集合,涵盖了从编程语言到开发框架等各个环节。

出海移动应用的 MAD 最佳实践

Android 自 08 年诞生之后的多年间 SDK 变化一直不大,开发方式较为固定。13 年起技术迭代开始加速, 17年之后随着 Kotlin 及 Jetpack 等新技术的出现 Android 开发方式发生了很大变化,去年推出的 Jetpack Compose 更是将这种变化推向了新阶段。Goolge 将这些新技术下的开发方式命名为 MAD ,以此区别于旧有的低效开发模式。

https://developer.android.com/series/mad-skills

MAD 可以指导开发者更高效地开发出优秀的移动应用,它的优势主要体现在以下几点:

  • 稳定可靠:相对于众多三方库,Google 的类库会长期维护,值得信赖
  • 接入友好:提供大量示例代码和参考文档,可以帮助你快速上手
  • 灵活适配:框架种类丰富多样,适用于不同阶段不同规模的项目
  • 体验一致:不同设备不同版本系统下具备一致的开发体验

MAD 助力应用出海

出海移动应用的 MAD 最佳实践

在 MAD 的指导下项目的代码架构也更加合理、更具可维护性。下图是项目中 MAD 的整体应用情况

出海移动应用的 MAD 最佳实践

接下来,本文将分享一些我们在对 MAD 实践过程中的心得和案例

一、Kotlin

出海移动应用的 MAD 最佳实践

Kotlin 是 Andorid 认可的首选开发语言,我们的项目中,所有代码都使用 Kotlin 开发。Kotlin 的语法十分简洁,相对于 Java 同等功能的代码规模可以减少 25%。此外 Kotlin 还具有很多 Java 所不具备的优秀特性:

1.1 Safety

Kotlin 在安全性方面有很多优秀的设计,比如空安全以及数据的不可变性。

  • Null Safety

Kotlin 的空安全特性让很多运行时 NPE 提前到编译期暴露和发现,有效降低线上崩溃的发生。我们在代码中重视对 Nullable 类型的判断和处理,我们在数据结构定义时都力求避免出现可空类型,最大限度降低判空成本;

interface ISelectedStateController<DATA> {
    fun getStateOrNull(data: DATA): SelectedState?
    fun selectAndGetState(data: DATA): SelectedState
    fun cancelAndGetState(data: DATA): SelectedState
    fun clearSelectState()
}

// 使用 Elvis 提前处理 Nullable
fun <DATA> ISelectedStateController<DATA>.getSelectState(data: DATA): SelectedState {
    return getStateOrNull(data) ?: SelectedState.NON_SELECTED
}

Java 时代我们只能通过 getStateOrNull 这类的命名规范来提醒返回值的可空,Kotlin 通过 让我们可以更好地感知 Nullable 的风险;我们还可以使用 Elvis 操作符 ?:Nullable 转成 NonNull 便于后续使用;Kotlin 的 !! 让我们更容易发现 NPE 的潜在风险并可以诉诸静态检查给予警告。

Kotlin 的默认参数值特性也有助于防止 NPE 的出现。像下面这样的结构体定义,在反序列化等场景中不必担心 Null 的出现。

data class BannerResponse(
    @SerializedName("data") val data: BannerData = BannerData(),
    @SerializedName("message") val message: String = "",
    @SerializedName("status_code") val statusCode: Int = 0
)

我们在全面拥抱 Kotlin 之后,NPE 方面的崩溃率只有 0.3‰,而通常 Java 项目的 NPE 会超过 1‰

  • Immutable

Kotlin 的安全性还体现在数据不会被随意修改。我们在代码中大量使用 data class 并且要求属性使用 val 而非 var 定义,这有利于单向数据流范式在项目中的推广,在架构层面实现数据的读写分离。

data class HomeUiState(
    val bannerList: Result<BannerItemModel> = Result.Success(emptyList()),
    val contentList: Result<ContentViewModel> = Result.Success(emptyList()),
)

sealed class Result<T> {
    data class Success<T>(val list: List<T> = emptyList()) : Result<T>()
    data class Error<T>(val message: String) : Result<T>()
}

如上,我们使用 data class 定义 UiState 用在 ViewModel 中。val 声明属性保证了 State 的不可变性。使用密封类定义 Result 有利于对各种请求结果进行枚举,简化逻辑。

private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

_uiState.value =
    _uiState.value.copy(bannerList = Result.Success(it))

需要更新 State 时,借助 data class 的 copy 方法可以快捷地拷贝构造一个新实例。Immutable 还体现在集合类的类型上。我们在项目中提倡非必要不使用 MutableList 这样的 Mutable 类型,可以减少 ConcurrentModificationException 等多线程问题的发生,同时更重要的是避免了因为 Item 篡改带来的数据一致性问题:

viewModel.uiState.collect {
    when (it) {
        Result.Success -> bannerAdapter.updateList(it.list)
        else {...}
    }
}

fun updateList(newList: List<BannerItemModel>) {
    val diffResult = DiffUtil.calculateDiff(BannerDiffCallback(mList, newList), true)
    diffResult.dispatchUpdatesTo(this)
}

比如上面例子中 UI 侧接收到 UiState 更新通知后,提交 DiffUtil 刷新列表。DiffUtil 正常运作的基础正是因为 mListnewList 保持了 Immutable 类型。

1.2 Functional

函数在 Kotlin 中是一等公民,可以作为参数或返回值的类型组成高阶函数,高阶函数可以在集合操作符等场景下提供更加易用的 API。

  • Collection operations
val bannerImageList: List<BannerImageItem> =
bannerModelList.sortedBy {
    it.bType
}.filter { 
    !it.isFrozen()
}.map { 
    it.image
}

上面的代码中我们对 BannerModelList 依次完成排序、过滤,并转换成BannerImageItem 类型的列表,集合操作符的使用让代码一气呵成。

  • Scope functions

作用域函数是一系列 inline 的高阶函数。它们可以作为代码的粘合剂,减少临时变量等多余代码的出现。

GalleryFragment().apply {
    setArguments(arguments ?: Bundle().apply {
        putInt("layoutId", layoutId())
    })
}.let { fragment ->
   supportFragmentManager.beginTransaction()
    .apply {
        if (needAdd) add(R.id.fragment_container, fragment, tag)
        else replace(R.id.fragment_container, fragment, tag)
    }.also{
        it.setCustomAnimations(R.anim.slide_in, R.anim.slide_out)
    }.commit()
}

当我们创建并启动一个 Fragment 时,可以基于作用域函数完成各种初始化工作,就像上面例子那样。这个例子同时也提醒我们过度使用这些作用域函数(或集合操作符),也会影响代码的可读性和可调试性,只有“恰到好处”的使用函数式编程才能真正发挥 Kotlin 的优势。

1.3 Corroutine

Kotlin 协程让开发者摆脱了回调地狱的出现,同时结构化并发的特性也有助于对子任务更好地管理,Android 的各种原生库和三方库在处理异步任务时都开始转向 Kotlin 协程。

  • Suspend function

在项目中,我们倡导使用挂起函数封装异步逻辑。在数据层 Room 或者 Retorfit 使用挂起函数风格的 API 自不必说,一些表现层逻辑也可以基于挂起函数来实现:

suspend fun doShare(
    activity: Activity,
    contentBuilder: ShareContent.Builder.() -> Unit
): ShareResult = suspendCancellableCoroutine { cont ->
    val shareModel = ShareContent.Builder()
        .setEventCallBack(object : ShareEventCallback.EmptyShareEventCallBack() {
            override fun onShareResultEvent(result: ShareResult) {
                super.onShareResultEvent(result)
                if (result.errorCode == 0) {
                    cont.resume(result)
                } else {
                    cont.cancel()
                }
            }
        }).apply(contentBuilder)
        .build()
    ShareSdk.showPanel(createPanelContent(activity, shareModel))
}

上例的 doShare 用挂起函数处理照片的分享逻辑:弹出分享面板供用户选择分享渠道,并将分享结果返回给调用方。调用方启动分享并同步获取分享成功或失败的结果,代码风格更符合直觉。

  • Flow

项目中使用 Flow 替代 RxJava 处理流式数据,减少包体积的同时,CoroutineScope 可以有效避免数据泄露:

fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> = 
    DatabaseManager.db.bannerDao::getAll.asFlow()
            .onCompletion {
                this@Repository::getRemoteBannerList.asFlow().onEach {
                    launch {
                        DatabaseManager.db.bannerDao.deleteAll()
                        DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
                    }
                }
            }.distinctUntilChanged()

上面的例子用于从多个数据源获取 BannerList 。我们增加了磁盘缓存的策略,先请求本地数据库数据,再请求远程数据。Flow 的使用可以很好地满足这类涉及多数据源请求的场景。而另一面在调用侧,只要提供合适的 CoroutineScope 就不必担心泄露的发生。

1.4 KTX

一些原本基于 Java 实现的 Android 库通过 KTX 提供了针对 Kotlin 的扩展 API,让它们在 Kotlin 工程中更容易地被使用。

我们的项目使用 Jetpack Architecture Components 搭建 App 基础架构,KTX 帮助我们大大降低了 Kotlin 项目中的 API 使用成本,举几个最常见的 KTX 的例子:

  • fragment-ktx

fragment-ktx 提供了一些针对 Fragment 的 Kotlin 扩展方法,比如 ViewModel 的创建:

class HomeFragment : Fragment() {
    private val homeViewModel : HomeViewModel by viewModels() 
    ... 
}

相对于 Java 代码在 Fragment 中创建 ViewMoel 变得极其简单,其背后的是现实活用了各种 Kotlin 特性,十分巧妙。

inline fun <reified VM : ViewModel> Fragment.viewModels(
    noinline ownerProducer: () -> ViewModelStoreOwner = { this },
    noinline factoryProducer: (() -> Factory)? = null
) = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)

viewModels 是 Fragment 的 inline 扩展方法,通过 reified 关键字在运行时获取泛型类型用来创建具体 ViewModel 实例:

fun <VM : ViewModel> Fragment.createViewModelLazy(
    viewModelClass: KClass<VM>,
    storeProducer: () -> ViewModelStore,
    factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        defaultViewModelProviderFactory
    }
    return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
}

createViewModelLazy 返回了一个 Lazy<VM> 实例,这似的我们可以通过by 关键字创建 ViewModel,这里借助 Kotlin 的代理特性实现了实例的延迟创建。

  • viewmodle-ktx

viewModel-ktx 提供了针对 ViewModel 的扩展方法, 例如viewModelScope,可以随着 ViewModel 的销毁及时终止过期的异步任务,让 ViewModel 更安全地作为数据层与表现层之间的桥梁使用。

viewModelScope.launch {
    //监听数据层的数据
    repo.getMessage().collect {
        //向表现层发送消息
        _messageFlow.emit(message)
    }
}

实现原理也非常简单

val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

viewModelScope 本质上是 ViewModle 的扩展属性,通过 custom get 创建 CloseableCoroutineScope 的同时,记录到 JOB_KEY 的位置中

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}

CloseableCoroutineScope 其实是一个 Closeable,在 ViewModel 的 onClear 时查找 JOB_KEY 并被调用 close 以取消 SupervisorJob ,终止所有子协程。KTX 活用了 Kotlin 的各种特性和语法糖 ,后面 Jetpack 章节会看到更多 KTX 的使用。

二、Android Jetpack

出海移动应用的 MAD 最佳实践

Android 通过 Jetpack 为开发者提供 AOSP 之上的基础能力支持,其范围覆盖了从 UI 到 Data 各个层级,降低了开发者们自造轮子的需求。近期 Jetpack 组件的架构规范又进行了全面升级,帮助我们在开发过程中能更好地贯彻关注点分离这一设计目标。

2.1 Architecture

Android 倡导表现层和数据层分离的架构设计,并使用单向数据流(Unidirectional Data Flow)完成数据通信。Jetpack 通过一系列 Lifecycle-aware 的组件支持了 UDF 在 Android 中的落地。

出海移动应用的 MAD 最佳实践

UDF 的主要特点和优势如下:

  • 唯一真实源(SSOT):UI State 在 ViewModel 集中管理,降低了多数据源之间的同步成本
  • 数据自上而下流动:UI 的更新来 VM 的状态变化,UI 自身不持有状态、不耦合业务逻辑
  • 事件自下而上传递:UI 发送 event 给 VM 对状态集中修改,状态变化可回溯、利于单测

项目中凡是涉及 UI 的业务场景都是基于 UDF 打造的。以 HomePage 为例,其中包括 BannerListContentList 两组数据展示,所有的数据集中管理在 UiState

class HomeViewModel() : ViewModel() {

    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    fun fetchHomeData() {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            with(repo) {
                //request BannerList
                try {
                    getBannerList().collect {
                        _uiState.value =
                            _uiState.value.copy(bannerList = Result.Success(it))
                    }
                } catch (ioe: IOException) {
                    // Handle the error and notify the UI when appropriate.
                    _uiState.value =
                        _uiState.value.copy(
                            bannerList = Result.Error(getMessagesFromThrowable(ioe))
                        )
                }
    
                //request ContentList
                try {
                    getContentList().collect {
                        _uiState.value =
                            _uiState.value.copy(contentList = Result.Success(it))
                    }
                } catch (ioe: IOException) {
                    _uiState.value =
                        _uiState.value.copy(
                            contentList = Result.Error(getMessagesFromThrowable(ioe))
                        )
                }
            }
        }
    
    }
}

如上代码所示,HomeViewModel 从 Repo 获取数据并更新 UiState,View 订阅此状态并刷新 UI。viewModelScope.launch 提供的 CoroutineScope 可以随着 ViewModel 的 onClear 结束运行中的协程,避免泄露。

数据层我们使用 Repository Pattern 封装本地数据源和远程数据源的具体实现:

class Repository {
    fun CoroutineScope.getBannerList(): Flow<List<BannerItemModel>> {

        return DatabaseManager.db.bannerDao::getAll.asFlow()
            .onCompletion {
                this@Repository::getRemoteBannerList.asFlow().onEach {
                    launch {
                        DatabaseManager.db.bannerDao.deleteAll()
                        DatabaseManager.db.bannerDao.insertAll(*(it.toTypedArray()))
                    }
                }
            }.distinctUntilChanged()
    }

    private suspend fun getRemoteBannerList(): List<BannerItemModel> {
        TODO("Not yet implemented")
    }
}

getBannerList 为例,先从数据库请求本地数据加速显示,然后再请求远程数据源更新数据,同时进行持久化,便于下次请求。

UI 层的逻辑很简单,订阅 ViewModel 的数据并刷新 UI 即可

@AndroidEntryPoint
class HomeFragment : Fragment()  {

    @Inject
    lateinit var viewModel : HomeViewModel
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

我们使用 Flow 代替 LiveData 对 UiState 进行封装,lifecycleScope 使得 Flow 变身 Lifecycle-aware 组件;repeatOnLifecycle 让 Flow 像 LiveData 一样在 Fragment 前后台切换时自动停止数据流的发射,节省资源开销。

2.2 Navigation

作为“单 Activity 架构”的实践者,我们选择了使用 Jetpack Navigation 作为 App 的导航组件。Navigation 组件实现了导航设计原则,为跨应用切换或应用内页面间的切换提供了一致的用户体验,并且提供了各种优势,包括:

  • 处理 Fragment 事务;
  • 默认情况下,正确处理往返操作;
  • 为动画和转场提供标准化资源;
  • 实现和处理深层链接;
  • 包括导航界面模式(例如抽屉式导航栏和底部导航),开发者只需完成极少的额外工作;
  • 提供 Gradle 插件用以保证在不同页面传递参数时类型安全;
  • 提供了导航图范围的 ViewModel,以在同导航图内的页面进行数据共享;
出海移动应用的 MAD 最佳实践

Navigation 提供了 XML 以及 Kotlin DSL 两种配置方式。我们在项目中发挥 Kotin 的优势,基于类型安全的 DSL 创建导航图,同时通过函数提取为页面统一指定转场动画:

fun NavHostFragment.initGraph() = run {
    createGraph(nav_graph.id, nav_graph.dest.home) {
        fragment<HomeFragment>(nav_graph.dest.effect_detail) {
            action(nav_graph.action.home_to_effect_detail) {
                destinationId = nav_graph.dest.effect_detail
                navOptions {
                    applySlideInOut()
                }
            }
        }
    }
}

//统一指定转场动画
internal fun NavOptionsBuilder.applySlideInOut() {
    anim {
        enter = R.anim.slide_in
        exit = R.anim.slide_out
        popEnter = R.anim.slide_in_pop
        popExit = R.anim.slide_out_pop
    }
}

在 Activity 中,调用 initGraph() 为 Root Fragment 初始化导航图:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    
    private val navHostFragment: NavHostFragment by lazy {
        supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        navHostFragment.navController.apply {
            graph = navHostFragment.initGraph()
        }
    }
}

而在 Fragment 中,使用 navigation-fragment-ktx 提供的 findNavController() 可以随时基于当前 Destination 进行正确的页面跳转:

@AndroidEntryPoint
class EffectDetailFragment : Fragment() {

    /* ... */
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        nextButton.setOnClickListener {
            findNavController().navigate(nav_graph.action.effect_detail_to_loading))
        }
        
        // Back to previous page
        backButton.setOnClickListener {
            findNavController().popBackStack()
        }
        
        // Back to home page
        homeButton.setOnClickListener {
            findNavController().popBackStack(nav_graph.dest.home, false)
        }
    }
}

除此以外,我们可以声明全局页面导航,这种方式在引导用户登录注册或前往反馈页等场景有很大用处:

fun NavHostFragment.initGraph() = run {
    createGraph(nav_graph.id, nav_graph.dest.home) {
        /* ... some Fragment destination declaration ... */
        // --------------- Global ---------------
        action(nav_graph.action.global_to_register) {
            destinationId = nav_graph.dest.register
            navOptions {
                applyBottomSheetInOut()
            }
        }
    }
}

2.3 Hilt

依赖注入 (Dependency Injection) 是多 Module 工程中的常用的技术,依赖注入作为控制反转设计原则的一种实现方式,有利于实例的生产侧与消费侧的解耦,践行了关注点分离的设计原则,也更有助于单元测试的编写。

出海移动应用的 MAD 最佳实践

Hilt 在 Dagger 的基础上构建而成,继承了 Dagger 编译时检查、运行时高性能、可伸缩等优点的同时提供了更友好的 API ,使得 Dagger 使用成本大幅降低。Android Studio 也内置了对 Dagger/Hilt 的支持,后文会介绍。

项目中大量使用了 Hilt 完成依赖注入,进一步提升了代码的编写效率。我们使用 @Singleton 提供 Repository 的单例实现,当 Repository 需要 Context 来创建 SharedPreferences 或者 DataStore 时,使用 @ApplicationContext 注解传入应用级别的 Context,在需要的地方只需要@Inject 即可注入对象:

@AndroidEntryPoint
class RecommendFragment : Fragment() {
    @Inject
    lateinit var recommendRepository: RecommendRepository
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        recommendRepository.doSomeThing()
    }
}

对于一些无法在构造函数中增加注解的三方库的类,我们可以使用 @Provides 来告诉 Hilt 如何创建相关实例。例如提供创建 Retorfit API 的实现,省去每次手动创建的工作

@Module
@InstallIn(ActivityComponent::class)
object ApiModule {
    
    @Provides
    fun provideRecommendServiceApi(): RecommendServiceApi {
        return Retrofit.Builder()
                .baseUrl("https://example.com")
                .build()
                .create(RecommendServiceApi::class.java)
    }
}

得益于 Hilt 对 Jetpack 其他组件的支持,在 ViewModel 或者 WorkManager 中也同样可以使用 Hilt 进行依赖注入

@HiltViewModel
class RecommendViewModel @Inject constructor(
    private val recommendRepository: RecommendRepository
) {
    
    val recommendList = recommendRepository.fetchRecommendList()
        .flatMapLatest {
            flow { emit(it) }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
}

2.4 WorkManager

WorkManager 是针对持久性工作而推出的 Jetpack 库,所谓持久性工作指可以跨越应用或者系统重启持续执行的任务,比如应用数据与服务器之间进行同步,或者是上传日志等。WorkManager 对内会根据策略自动选择 FirebaseJobDispatcher、GcmNetworkManager 或 JobScheduler 等执行调度任务,对外则提供了简单一致的 API 方便使用。

出海移动应用的 MAD 最佳实践

WorkManager 默认使用 Jetpack StartUp 库进行初始化,开发者只需关注定义与实现 Worker 即可,无需其他额外工作。WorkManager 向后兼容到 Android 6.0 、覆盖了市面上绝大多数的机型,可以有效取代 Service 完成那些需要长期执行的后台任务。

产品为了减少用户生成头像时上传图片所需时间与流量消耗,会在上传之前对图片进行压缩,但是压缩过程的临时文件会增加 App 所占存储空间,所以我们使用 WorkManager 对清理压缩图片缓存的工作进行调度,在 App 启动后将任务提交给 WorkManager:

val deleteImageCacheRequest = OneTimeWorkRequestBuilder<DeleteImageCacheWorker>().build()
WorkManager.getInstance(this).enqueue(deleteImageCacheRequest)

class DeleteImageCacheWorker(
    context: Context, 
    workParams: WorkerParameters
) : Worker(context, workParams) {

    override fun doWork(): Result {
        return try {
            /* ... do the work ... */
            Result.success()
        } catch (e: Exception) {
            /* return failure() or retry() */
            Result.failure()
        }
    }
}

还有一种场景是用户下载图片。下载需要网络,并且此工作的优先级比较高,因此可以使用 WorkManager 提供的工作约束以及加急工作 (WorkManager 2.7 及以上) 等能力,除此以外还可以对工作的结果信息进行监听,以对用户进行提示:

val downloadImageRequest = OneTimeWorkRequestBuilder<DownLoadImageWorker>()
    .setInputData(workDataOf("url" to "https://the-url-of-image.com"))
    // set network constraint
    .setConstraints(
        Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
    )
    // make worker expedited
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()
WorkManager.getInstance(context).enqueue(downloadImageRequest)

val downloadImageFlow = WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(downloadImageRequest.id)
    .asFlow()
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        replay = 1
    )

// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        downloadImageFlow.collectLatest {
            when (it?.state) {
                WorkInfo.State.ENQUEUED -> {}
                WorkInfo.State.RUNNING -> {}
                WorkInfo.State.SUCCEEDED -> {}
                WorkInfo.State.BLOCKED -> {}
                WorkInfo.State.FAILED -> {}
                WorkInfo.State.CANCELLED -> {}
            }
        }
    }
}

2.5 StartUp

应用启动时需要做大量初始化工作,例如 SDK 的初始化、基础模块的配置等。StartUp 出现之前我们使用 ContentProvider 完成“无侵”的初始化,避免 init(Context) 这类代码在 Application 中的出现。但是 ContentProvider 的创建成本较高,多个 ContentProvider 同时创建会拖慢应用启动速度且初始化时序不可控。

StartUp 只使用一个 ContentProvider 来完成多个组件的初始化,很好地解决了上述 ContentProvider 的各种问题。此外,StartUp 还可以避免 app 模块对其他模块的非必要依赖。例如我们在项目中需要为 local test 渠道单独依赖一个 Module,此 Module 依赖 Context 完成初始化,但我们不希望它被打入 release 包。此时要像下面这样添加 Gradle 依赖即可,app 不需要在代码层面依赖 local_test 模块

if (BuildContext.isLocalTest()) {
    implementation project(':local_test')
}

StartUp 库的使用非常简单,只需定义一个 Initializer 即可, 定义的同时还可以配置初始化的依赖项,确保核心组件可以最先完成初始化:

class ServerInitializer : Initializer<ServerManager> {
    override fun create(context: Context): ServerManager {
        TODO("init ServerManager and return")
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList()
    }
}

class AccountInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        TODO("init Account")
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return listOf(ServerInitializer::class.java)
    }
}

在上面的例子中,Account 模块的初始化将会等待 Server 模块初始化完成后才会继续。

2.6 Room

local-first 架构的 App 可以提供良好的用户体验,当设备无法访问网络时,用户仍可在离线状态下浏览相应内容。Android 提供了 SQLite 作为访问数据库的API,但是 SQLite API 比较底层,需要人工确保 SQL 语句的正确性,除此以外,还需要编写大量的模板代码来完成 PO 与 DO 之间的转换。Jetpack Room 在 SQLite 的基础上提供了一个抽象层,帮助开发者更流畅地访问数据库。

Room 主要包含3个组件:Database 是数据库持有者,是与底层数据库连接的主要接入点;Entity 代表数据库中的表;DAO 包含用于访问数据库的方法。3个组件通过注解进行声明:

@Entity(tableName = "tb_banner")
data class Banner(
    @PrimaryKey
    val id: Long,
    @ColumnInfo(name = "url")
    val url: String
)

@Dao
interface BannerDao {
    @Query("SELECT * FROM tb_banner")
    fun getAll(): List<Banner>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertBanner(banner: Banner)
}

@Database(entities = arrayOf(Banner::class), version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract 

需要注意的是创建数据库的成本比较高,所以单进程 App 内要保证数据库为单例:

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext applicationContext: Context
    ): AppDatabase {
        return Room.databaseBuilder(
                    applicationContext,
                    AppDatabase::class.java, "database-name"
                ).build()
    }
    
    @Provides
    @Singleton
    fun provideBannerDao(
        appDatabase: AppDatabase
    ): BannerDao {
        return appDatabase.bannerDao()
    }

}

当数据库中的数据发生更新时,我们希望 UI 也能随之自动刷新。得益于 Room 对 Coroutine 以及 RxJava 良好的支持,只需要引入 room-ktx 库或者 room-rxjava2/3 库,DAO 中的方法也可以直接返回 Flow 或者 Observable,或者直接使用挂起函数:

@Dao
interface BannerDao {
    @Query("SELECT * FROM tb_banner")
    fun getAll(): Flow<List<Banner>>

    @Query("SELECT * FROM tb_banner")
    suspend fun getAllSuspend(): List<Banner>>
}

这时候我们只需要在 UI 层对 Flow 进行订阅,便可以做到当数据库内容更新时 UI 也随之更新:

@HiltViewModel
class BannerViewModel @Inject constructor (
    // we should use repository rather than access BannerDao directly
    private val bannerDao: BannerDao
) : ViewModel() {
    
    val bannerList: Flow<BannerVO> = bannerDao.getAll().map {
        it.toVO()
    }
}

// in Fragment
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        bannerViewModel.bannerList.collectLatest {
            bannerAdapter.submitList(it)
        }
    }
}

更进一步,在 UI 层我们只订阅数据库中的数据,而在后台使用 WorkManager 发起网络请求,获取到数据后再将最新的数据写入到数据库中。由于数据库访问速度远远快于网络,因此页面可以更快地呈现给用户。

三、Android Studio

出海移动应用的 MAD 最佳实践

Android Studio 诞生至今一直保持着活跃的版本更新,当前最新版本已经更新至 Bumblebee | 2021.1.1.21 ,自 4.3 Canary 1 以来 Android Studio 在命名风格上有所调整,更好地对齐了 IntelliJ 平台版本。除了稳定版,开发者还可以下载 Preview 版提前体验更多新鲜功能。

随着版本的不断更新,编写和调试代码的体验得到持续的优化,且集成了越来越多的新功能。Layout Instpector ,Device Exploer 等既有功能自不必说,以下这些新特性也为我们的开发、调试提供了巨大的便利。

3.1 Database Inspector

出海移动应用的 MAD 最佳实践

我们使用 Room 进行数据持久化,Database Inspector 可以实时查看 Jetpack Room 框架生成的数据库文件,同时也支持实时编辑和部署到设备当中。相较之前需要的 SQLite 命令或者额外导出并借助 DB 工具的方式更为高效和直观。

3.2 Realtime Profilers

出海移动应用的 MAD 最佳实践

Android Studio 的 Realtime Profilers 工具可以帮助我们在如下四个方面监测和发现问题,有时在缺少工程代码的情况下通过 Memory Profilers 还可以查看其内部的实例和变量细节。

  • CPU:性能剖析器检查 CPU 活动,切换到 Frames 视图还可以追踪界面卡顿
  • Memory:识别可能会导致应用卡顿、冻结甚至崩溃的内存泄漏和内存抖动,可以捕获堆转储、强制执行垃圾回收以及跟踪内存分配以定位内存方面的问题
  • Battery:会监控 CPU、网络无线装置和 GPS 传感器的使用情况,并直观地显示其中每个组件消耗的电量,了解应用在哪里耗用了不必要的电量
  • Network:显示实时网络活动,包括发送和接收的数据以及当前的连接数。这便于您检查应用传输数据的方式和时间,并适当优化代码

3.3 APK Analyzer

出海移动应用的 MAD 最佳实践

Apk 的下载会耗费网络流量,安装了还会占用存储空间。其体积的大小会对 App 安装和留存产生影响,分析和优化其体积显得尤为必要。

借助 AS 的 APK Analyzer 可以帮助完成如下几项工作:

  • 快速分析 Apk 构成,包括 DEX、Resources 和 Manifest 的 Size 和占比,助力我们优化代码或资源的方向
  • Diff Apk 以了解版本的前后差异,精准定位体积变大的源头
  • 分析其他 Apk,包括查看大致的资源和分析代码逻辑,进而拆解、Bug 定位

3.4 DI Navigation

依赖注入有助于模块间的解耦,践行了关注点分离的设计原则。我们使用 Dagger/Hilt 通过编译期代码生成隐藏了相关具体实现,这在降低构建依赖关系图的成本的同时,也增加了开发者调试代码的成本:寻找被注入实例的来源变得困难起来。

如今 Android Studio 帮开发者解决了这个痛点。自 4.1 我们可以在基于 Dagger 的代码(例如 Components,Subcomponents,Modules 等)中跳转,找寻依赖关系

出海移动应用的 MAD 最佳实践

在 Dagger 或 Hilt 相关的代码旁可以看到下面的 icon

出海移动应用的 MAD 最佳实践

点击左侧 icon 可以跳转到实例对象的提供处,点击右侧 icon 则可以跳转到对象的使用处,当有多处使用时则会给出候选列表供选择。Android 4.2 起还增加了对 @EnterPoint 的依赖查询,对于 ContentProvider 这样的不能自动注入的组件,也可以通过 Hilt 扩大依赖注入的使用范围。

四、App Bundle

出海移动应用的 MAD 最佳实践

Android App Bundle 是 Google 推出的用于动态化分发的打包格式。当应用程序以 AAB 的格式上传 Google Play(或其他支持 AAB 的应用市场)后,可以根据需要实现功能或资源的动态下发。

出海移动应用的 MAD 最佳实践

Split APKs 机制是 AAB 实现动态下发的基础,AAB 上传 GP 后被拆分成一个 base APK 和多个 Split APKs。首次下载只下发 Base APK,然后根据使用场景动态下发 Split APKs。Split 可以是 Configuration APKs ,也可以是一个 Dynamic Features APKs

  • Configuration APKs:根据 language,density,abi 三个维度拆分资源,比如 res/drawable-xhdpi 会被拆分到 xhdpi 的 Apk 中,res/values-en 会被拆分到 en 的 apk 中,当 Configurations Changed 发生时请求必要资源
  • Dynamic Features APKs:可以实现 Feature 的按需动态加载,这类似于国内流行的“插件化”技术,通过将一些非常用的功能做成 Dynamic Feature 可以实现功能的按需加载。

Google 重视 AAB 格式的推广,自 21 年 8 月起,规定新 App 必须使用 AAB 格式才能在 Google Play 上架。作为一款要在海外上架的产品,我们自然也选择了 AAB 的交付方式,除了在包体积方面的显著受益,也较好地助力了产品推广和装机率的提升

出海移动应用的 MAD 最佳实践

4.1 Language Split

我们的应用在多个国家同时上架,需要支持英语、印尼语、葡语等多种语言,借助 AAB 可以避免下载其他国家的语言资源。

语言动态下发非常简单,首先在 Gradle 开启 languageenableSplit

bundle {
    language {
        enableSplit = true
    }
}

切换系统语言时,应用会通过 GP 自动下载所需的语言。当然也可以根据业务需求手动请求语言资源,比如在我们内置的语言切换界面中选择其他语言时

private val _splitListener = SplitInstallStateUpdatedListener { state ->
    val lang = if (state.languages().isNotEmpty()) 
                state.languages().first() else ""
    when (state.status()) {
        SplitInstallSessionStatus.INSTALLED -> {
            //...
        }
        SplitInstallSessionStatus.FAILED -> {
            //...
        }
        else -> {}
    }
}

//创建SplitManager  并注册回调
val splitManager = SplitInstallManagerFactory.create(requireContext())
splitManager.registerListener(_splitListener)


//安装语言资源
val request = SplitInstallRequest.newBuilder()
    .addLanguage(Locale.forLanguageTag(language))
    .build()
splitManager.startInstall(request);

4.2 Dynamic Feature

产品中有一些高级功能,并非所有用户都会用到,比如某些高级相机特效,却依赖了比较多的 so 以及底层库,将它们做成 Dynamic Feature 实现功能的按需加载:

创建 Dynamic Feature 就如同创建一个 Gradle Module

出海移动应用的 MAD 最佳实践

DF 创建时可以配置两种下载方式:

  • on-demand:是否走动态下发,如果勾选,表示根据用户请求去动态下载,否则用户安装 Apk 时Module 就会被安装
  • fusing:此配置主要是为了兼容5.0以下不支持 AAB 的情况,如果勾选,在5.0以下设备会直接安装 Module,否则,5.0以下设备不包含此 Module

DF 创建后会在 app/build.gradle 中添加响应注册

dynamicFeatures = [':dynamicfeature']

在需要的场景请求 Dynamic Feature,与请求语言的代码类似,都是使用 SplitInstallManager

val splitManager = SplitInstallManagerFactory.create(requireContext())

//动态安装模块
SplitInstallRequest request =
    SplitInstallRequest
        .newBuilder()
        .addModule("FaceLab")
        .addModule("Avator")
        .build();

splitManager
    .startInstall(request)
    .addOnSuccessListener { sessionId -> ... }
    .addOnFailureListener { exception -> ... }

4.3 Bundletool

AAB 格式没法在本地安装和调试,通过 Google 提供的 AAB > APK 的打包工具,我们可以在本地编译成 APK ,便于 QA 的测试和开发人员的自测。AAB 生成 APK 的过程如下,中间会生成 .apks ,然后再针对不同设备生成具体 .apk

出海移动应用的 MAD 最佳实践

// 通过 aab 生成 apks 文件bundletool build-apks --bundle=/MyApp/my_app.aab --output=/MyApp/my_app.apks --ks=/MyApp/keystore.jks--ks-pass=file:/MyApp/keystore.pwd--ks-key-alias=MyKeyAlias--key-pass=file:/MyApp/key.pwd--device-spec=file:device-spec.json

通过 device.json 生成本地 Apk

bundletool extract-apks 
--apks=${apksPath} 
--device-spec={deviceSpecJsonPath} 
--output-dir={outputDirPath}

也可以直接通过 apks 进行安装,此时实际上是安装 APK 到手机上,只是该命令会自动读取手机配置,然后先生成相应的 APK,再安装到手机

bundletool install-apks 
--apks=/MyApp/my_app.apks

最终的安装包通过语言等资源以及 Dynamic Feature 的动态下发,包体积减小近 40%,从 90M 压缩到 55M。

五、ML Kit

出海移动应用的 MAD 最佳实践

除了 Jetpack 的相关类库, Google 还为我们的应用提供了不少其他技术支持,比如 MLKit 。

MLKit 是 Google 推出的针对移动端的一款移动 SDK,支持 Android 与 iOS 平台,封装了文字识别、人脸检测、对象跟踪及检测等诸多机器学习能力,对于机器学习开发者,MLKit 也同样提供了 API 帮助开发者自定义 TensorFlow lite 模型。MLKit 也支持 Google Play 运行时下发,以减少包体积。

作为一款 AI 特效应用,需要支持用户选择多人脸图片中的某个人脸进行渲染,因此人脸位置检测能力必不可少,经过调研,我们选择了 MLKit 来实现快速人脸检测。

出海移动应用的 MAD 最佳实践

MLKit 将几种机器学习能力进行了拆分,App 只需引入需要的能力即可。以人脸检测为例,引入人脸检测 Google Play 动态下发库,并使用挂起函数简化 API 的使用:

dependencies {
    implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.0.0'
}

在 AndroidManifest.xml 文件中进行配置

<application ...>
        ...
    <meta-data
        android:name="com.google.mlkit.vision.DEPENDENCIES"
        android:value="face" />
</application>    

使用协程提供的 suspendCancellableCoroutine 将回调改造成挂起函数

suspend fun faceDetect(input: Bitmap): List<Face> = suspendCancellableCoroutine { continuation ->
    val image = InputImage.fromBitmap(bitmap, 0)
    val detector = FaceDetection.getClient()
    detector.process(image)
        .addOnSuccessListener {
            continuation.resumeWith(Result.success(it))
        }
        .addOnFailureListener {
            continuation.resumeWithException(RuntimeException(it))
        }
        .addOnCanceledListener {
            continuation.cancel()
        }
}

最后

MAD 帮助我们加速了开发、实现了产品的快速上架,未来我们还会引入 Jetpack Compose 进一步提升开发效率、缩短迭代周期。篇幅所限,文中内容只能点到为止,希望能够为更多出海应用在技术选型上提供启发和参考。

随着 Jetpack 为代表的谷歌移动开发生态的不断完善,开发者可以将精力聚焦到业务创新,为用户开发出更多丰富的功能。底层类库的统一也有利于开发者们更好地展开技术交流和共建,摆脱各自为战、重复造轮子的开发窘境。

这里就还给大家分享一套完备的知识体系,整体参照了各家一线大厂高工岗位的招聘要求及岗位技能需求,并且每个具体的知识节点还附带有一整套体系化的学习资料、笔记进行辅助,帮助你把所学的知识点全部串联起来!

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取

出海移动应用的 MAD 最佳实践

01.Android必备底层技术

  • Java序列化:Serializable原理、Parcelable接口原理、Json、XML
  • 注解、泛型与反射:自定义注解、注解的使用、泛型擦除机制、泛型边界、Java方法与Arm指令、Method反射源码、invoke方法执行原理
  • 虚拟机:JVM垃圾回收器机制、JVM内存分配策略、Android虚拟机与JVM底层区别、虚拟机底层Odex本地指令缓存机制、虚拟机如何分别加载class与object、虚拟机类加载模型
  • 并发:Java线程本质讲解、线程原理、线程通信、UnSafe类、线程池
  • 编译时技术:OOP面向切面之AspectJ、字节码手术刀JavaSSit实战、字节码插桩技术(ASM)实战
  • 动态代理:动态代理实现原理、动态代理在虚拟机中运行时动态拼接Class字节码分析、ProxyGenerator生成字节码流程
  • 高级数据结构与算法:HashMap源码、ArrayList源码、排序算法
  • Java IO:Java IO体系、IO文件操作
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

02.Framework

  • Binder:Linux内存基础、Binder四层源码分析、Binder机制、Binder进程通信原理
  • Handler:Loop消息泵机制、Message解析
  • Zygote:init进程与Zygote进程、Zygote启动流程、Socket通信模式、APP启动过程
  • AMS:ActivityThread源码分析、AMS与ActivityThread通信原理、Activity启动机制
  • PMS:PMS源码、APK安装过程分析、PMS对安装包的解析原理
  • WMS:PhoneWindow实例化流程、DecorView创建过程、ViewRootImpl渲染机制
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

03.Android常用组件

  • Activty:Activity管理栈与Activity的启动模式、Activity生命周期源码分析
  • Fragment:Fragment生命周期深入详解、Fragment事务管理机制详解、性能优化相关方案
  • Service:Service启动模式分析、Service管理与通信方案、Service生命周期底层详解
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

04.高级UI

  • UI绘制原理:setContentView()方法下到底做了什么、AppCompatActivity与Activity的区别、UI测量、布局、绘制的底层执行流程
  • 插件换肤:LayoutInflater加载布局分析、Android资源的加载机制、Resource与AssetManager
  • 事件分发机制原理:事件执行U形链与L形链、事件拦截原理
  • 属性动画:VSYNC刷新机制、ObjectAnimator与ValueAnimator源码讲解、Android属性动画:插值器与估值器
  • RecycleView:布局管理器LayoutManager详解、回收池设计思想、适配器模式原理
  • 高阶贝塞尔曲线
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

05.Jetpack

  • Lifecycle:Lifecycle源码、Lifecycle高阶应用
  • ViewModel:ViewModel源码、ViewModel应用技巧
  • LiveData:LiveData源码
  • Navigation:Navigation源码
  • Room:Room源码、Room+LiveData监听数据库数据变更刷新页面原理
  • WorkManager内核
  • Pagging原理
  • DataBinding:单向绑定、双向绑定、如何与RecyclerView的配合使用、底层原理
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

06.性能优化

  • 启动优化:系统启动原理、Trace工具分析启动卡顿、类重排机制、资源文件重排机制
  • 内存优化
  • UI渲染优化:UI层级规范及对UI加载的影响、UI卡顿原因及修复、UI绘制、布局、测量原因以及处理方案
  • 卡顿优化:造成卡顿的原因分析、内存抖动与GC回收、回收算法
  • 耗电优化
  • 崩溃优化:项目崩溃异常捕获、优雅的异常处理方案、如何避免异常弹框
  • 安全优化:APP加固实现(防反编译,dex加固)、https防抓包机制(数据传输加载,客户端服务器端双向加密校验)
  • 网络优化:serializable原理、parcelable接口原理、http与https原理详解、protbuffer网络IO详解、gzip压缩方案
  • 大图加载优化:Glide巨图加载机制原理分析、大图多级缓存实现方案
  • 多线程并发优化
  • 储存优化:Android文件系统-sdcard与内存存储、Shared Preference原理、MMAP内存映射
  • 安装包优化:shrinkResources去除无用资源、合理设置多语言、webp实现图片瘦身、合理配置armable-v7的so库、Lint检查工具实践
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取

07.音视频

  • C/C++:数据类型、数组、内存布局、指针、函数、预处理器、结构体、共用体、容器、类型转换、异常、文件流操作、线程
  • H.265/H.265:音视频格式封装原理、编码原理、视频流H264的组装原理切片NAL单元、视频流H264码流分析、切片与宏快,运动矢量、信源编码器、高频滤波、帧间拆分与帧内预测、CTU,PU TU编码结构、DSP芯片解码流程、MediaPlayer与DSP芯片交互机制、投屏架构、MediaProjection与MeidiaCodec交互机制、H265码流交换
  • MediaCodec:dsp芯片、编解码器的生命周期、解码器中输入队列与解析队列设计思想、MediaCodec中平缓解码解析、MediaExtractor 多路复用、MediaMuxer合成器、MediaFormat格式
  • 音视频剪辑:视频剪辑、音频剪辑、音频合成、音谱显示、视频倒放
  • 音视频直播:硬编码、软编码、native实现rtmp推流、摄像头预览帧编码NV21转YUV、视频画面封装拼接Packet包、音频流数据拼接Packet包、RtmpDump实时同步发送音视频数据、MediaProjection、Medicodec编码H264码流、rtmp推流
  • OpenGL与音视频解码:OpenGL绘制流程、矩阵、Opencv详解、人脸识别效果实现
  • OpenGL特效:CPU与GPU运行机制详解、世界坐标,布局坐标,与FBO坐标系、图像镜像与旋转处理、人脸定位与关键点定位、大眼效果、贴纸效果、美颜效果
  • FFmpeg万能播放器:FFmpeg结构体、声音播放原理、Surface的渲染、像素绘制原理与对齐机制、音视频同步原理、视频播放器整体架构
  • Webrtc音视频通话:WebRtc服务端环境搭建与Webrtc编译、1v1视频通话实现方案、群聊视频通话实现思路、多对多视频会议实现、1V1音视频通话实现
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

08.开源框架原理

  • Okhttp
  • Retrofit
  • RxJava
  • Glide
  • Hilt
  • Dagger2
  • EventBus
  • 组件化、插件化、热修复等
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

09.Gradle

  • Groovy语法
  • Gradle Android插件配置
  • Gradle实践等
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

10.kotlin

  • Kotlin语法
  • 扩展使用
  • 进阶使用
  • 实践等
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

11.Flutter

  • Dart语法
  • UI
  • 进阶使用
  • 优化
  • 实践等
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

12.鸿蒙

  • Ability组件
  • 分布式任务
  • 事件总线
  • 鸿蒙线程
  • UI自定义控件等
出海移动应用的 MAD 最佳实践

出海移动应用的 MAD 最佳实践

如果需要的话,可以顺手帮我点赞评论一下,直接私信我【笔记】免费领取

Android路漫漫,共勉!

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/57164.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信