An enterprise-grade Android architecture practice project demonstrating Clean Architecture with modularization. This project serves as a comprehensive reference for building scalable, maintainable Android applications using modern Android development practices.
- Demonstrate Clean Architecture with clear separation of concerns (Presentation/Domain/Data layers)
- Showcase modularization patterns for large-scale Android applications
- Provide production-ready code with MVVM, Hilt DI, Coroutines, and Flow
- Reference implementation for networking, storage, state management, and UI patterns
- 🏗️ Modular Architecture: Base, Common, Component, and Feature modules with clear dependencies
- 💉 Hilt Dependency Injection: Complete DI setup from presentation to data layers
- 🌐 Robust Networking: Retrofit + OkHttp with ServiceContext wrapper for API calls
- 💾 Flexible Storage: StorageManager abstraction supporting SharedPreferences and MMKV
- 🔄 State Management: StateD pattern with LiveData and Flow support
- 🎨 Custom UI Components: ShapeableView and DView for drawable-less UI
- 🧩 API-First Design: Features expose capabilities through API modules, not implementations
- 📱 View & Compose: Support for both traditional View system and Jetpack Compose
- Quick Start
- Architecture Overview
- Module Structure
- Core Capabilities
- State Management
- UI Components
- Development Guidelines
- Build System
- Contributing
- JDK: 11 or higher
- Android Studio: Jellyfish (2023.3.1) or newer
- Git: For cloning and submodules
# Clone the repository
git clone git@github.com:Ztiany/android-architecture-practice.git
cd android-architecture-practice
# Initialize and update submodules
git submodule init
git submodule update# Build debug APK
./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
# Clean build
./gradlew clean
# Run tests
./gradlew test
# Run lint checks
./gradlew lintIf build fails after pulling changes:
Set forceSubstitution = true in settings.gradle.kts (line 45):
// In settings.gradle.kts
forceSubstitution = true // Force use of local modulesIf Gradle daemon issues occur:
# Stop daemon and retry
./gradlew --stop
./gradlew assembleDebugFor module substitution development:
# Publish all base modules to MavenLocal
./gradlew publishAllBaseProjectToMavenLocalThis project follows Clean Architecture principles with three main layers:
┌─────────────────────────────────────────┐
│ Presentation Layer (MVVM) │
│ Fragments/Activities + ViewModels │
│ State Management (LiveData/Flow) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Domain Layer │
│ Repositories + Use Cases │
│ Business Logic Abstraction │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Data Layer │
│ Retrofit + OkHttp + Room + Storage │
│ Data Sources & Persistence │
└─────────────────────────────────────────┘
The entire project uses Hilt for dependency injection:
- Presentation: Fragments/Activities → ViewModels/Repositories
- Inter-module: Features depend on APIs through interfaces
- Global DI container: Manages object lifecycles
Philosophy: Activity and Fragment components shouldn't worry about creating ViewModels or Repositories. Instead, they declare dependencies, and the container (Hilt) provides them.
Each feature follows the API-First Design pattern:
feature/account/
├── api/ # Interfaces and capabilities exposed to other features
│ └── AccountApi.kt
├── main/ # Core implementation of the feature
│ ├── implementation/
│ ├── presentation/
│ └── domain/
└── app/ # (Optional) Feature-specific app module
Golden Rule: Features should only depend on:
base/*modulescommon/*modules- Other feature
:feature/*/apimodules (nevermain)
| Module | Purpose |
|---|---|
activity/ |
Base Activity classes |
adapter/ |
Adapter implementations |
core/ |
Core utilities |
fragment/ |
Base Fragment classes (core UI framework) |
utils/ |
General utilities |
view/ |
Custom views (ShapeableView, DView) |
viewbinding/ |
ViewBinding utilities |
| Module | Purpose |
|---|---|
api/ |
Common API interfaces |
core/ |
Business core logic |
http/ |
HTTP client & networking (Retrofit/OkHttp) |
ui-dialog/ |
Dialog components |
ui-theme/ |
Theme resources |
ui-widget/ |
UI widgets |
ui-compose/ |
Compose UI components |
| Module | Purpose |
|---|---|
upgrade/ |
App upgrade functionality |
selector/ |
Media/file selector |
uitask/ |
UI task manager (global UI task management) |
| Module | Purpose |
|---|---|
account/ |
Account management (login, user info) |
home/ |
Home feature (dashboard, tabs, navigation) |
browser/ |
Browser feature |
interface AccountApi {
@POST("novaAccountLogin")
suspend fun pwdLogin(@Body loginRequest: LoginRequest): ApiResult<LoginResponse>
}@Module
@InstallIn(ActivityRetainedComponent::class)
object AccountInjectionModule {
@Provides
fun provideAccountApi(
apiServiceFactoryProvider: ApiServiceFactoryProvider
): ServiceContext<AccountApi> {
return apiServiceFactoryProvider.default.createServiceContext(AccountApi::class.java)
}
}class AccountRepository @Inject constructor(
private val accountApi: ServiceContext<AccountApi>,
private val userManager: UserManager,
private val dispatcherProvider: DispatcherProvider,
) {
suspend fun pwdLogin(account: String, password: String): User {
val loginRequest = LoginRequest(
userAccount = account,
pwd = password,
validateCode = "",
loginType = LOGIN_TYPE_NORMAL
)
val loginResponse = withContext(dispatcherProvider.io()) {
accountApi.executeApiCall { pwdLogin(loginRequest) }
}
userManager.updateUser {
it.copy(
isFirstLogin = loginResponse.firstLoginFlag.isPositiveApiFlag(),
merchantList = loginResponse.merInfoList.map { merchant ->
Merchant(
merchantId = merchant.merchantId,
merchantName = merchant.merchantName
)
}
)
}
return userManager.user
}
}// Returns CallResult for handling success/error
suspend fun <T : Any> apiCall(
call: suspend Service.() -> Result<T>
): CallResult<T>
// Direct result, throws on failure
suspend fun <T : Any> executeApiCall(
call: suspend Service.() -> Result<T>
): T
// Allows nullable data field
suspend fun <T : Any?> apiCallNullable(
call: suspend Service.() -> Result<T>?
): CallResult<T?>
// With retry support
suspend fun <T : Any> apiCallRetry(
retryDeterminer: RetryDeterminer,
call: suspend Service.() -> Result<T>
): CallResult<T>Using StorageManager for data persistence:
@Singleton
class UserManagerImpl @Inject constructor(
@ApplicationScope private val coroutineScope: CoroutineScope,
storageManager: StorageManager,
) : UserManager {
private val userStorage = storageManager.newStorage("app_user_storage_id")
init {
val loadedUser = userStorage.getEntity("app_user_key") ?: User.NOT_LOGIN
userFlow = MutableStateFlow(loadedUser)
coroutineScope.launch {
userFlow.onEach { user ->
if (user != User.NOT_LOGIN) {
userStorage.putEntity("app_user_key", user)
}
}.collect()
}
}
}interface Storage {
// Primitives
fun putInt(key: String, value: Int)
fun putLong(key: String, value: Long)
fun putBoolean(key: String, value: Boolean)
fun putString(key: String, value: String?)
// Objects with JSON serialization
fun putEntity(key: String, entity: Any?)
fun putEntity(key: String, entity: Any?, cacheTime: Long)
// Getters
fun getInt(key: String, defaultValue: Int): Int
fun getLong(key: String, defaultValue: Long): Long
fun getBoolean(key: String, defaultValue: Boolean): Boolean
fun getString(key: String): String?
fun <T> getEntity(key: String, type: Type): T?
// Removal
fun remove(key: String)
fun clearAll()
}- SharedPreferences: Default implementation (recommended for user data)
- MMKV: Alternative high-performance implementation (
⚠️ Not recommended for critical user data due to lack of backup mechanism)
Basic UI capabilities with toast and dialog support:
class ComponentFragment : BaseUIFragment<SampleFragmentComponentBinding>() {
override fun onSetUpCreatedView(view: View, savedInstanceState: Bundle?) = withVB {
sampleTvDialogConfirm.onDebouncedClick {
showConfirmDialog()
}
}
private fun showConfirmDialog() {
showConfirmDialog {
message = "Confirm action"
negativeText = "Cancel"
positiveText = "Confirm"
positiveListener = {
// Handle confirmation
}
cancelableTouchOutside = false
}
}
}Multi-state layout support (loading/empty/error):
Layout Requirements:
- Must include
SimpleMultiStateLayoutwith idbase_state_layout - Optional
ScrollChildSwipeRefreshLayoutwith idbase_refresh_layout
<com.android.base.fragment.widget.ScrollChildSwipeRefreshLayout
style="@style/Widget.App.SwipeRefreshLayout">
<com.android.base.fragment.widget.SimpleMultiStateLayout
style="@style/Widget.App.SimpleMultiStateLayout">
<androidx.recyclerview.widget.RecyclerView
android:id="@id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.android.base.fragment.widget.SimpleMultiStateLayout>
</com.android.base.fragment.widget.ScrollChildSwipeRefreshLayout>List handling capabilities with adapter and pagination support.
UI states are represented as StateD with three possible states:
- Loading: Request in progress
- Error: Request failed
- Success: Request succeeded (with or without data)
@HiltViewModel
class PasswordLoginViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val appSettings: AppSettings,
) : ViewModel() {
private val _loginState = MutableLiveData<StateD<User>>()
val loginState: LiveData<StateD<User>> = _loginState
fun smsLogin(phone: String, password: String) {
_loginState.setLoading()
viewModelScope.launch {
try {
val user = accountRepository.pwdLogin(phone, password)
_loginState.setData(user)
appSettings.agreeWithUserProtocol = true
} catch (e: Exception) {
ensureActive()
_loginState.setError(e)
}
}
}
}@AndroidEntryPoint
class PasswordLoginFragment : BaseUIFragment<AccountFragmentPasswordBinding>() {
private val viewModel: PasswordLoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
subscribeViewModel()
}
private fun subscribeViewModel() {
handleLiveData(viewModel.loginState) {
onData { user ->
// Success with data
showMessage(R.string.login_success)
navigator.exitAndToHomePage()
}
}
}
}handleLiveData(viewModel.loginState) {
onLoading {
// Called when state is Loading
}
onData { data ->
// Called when state is Success with data
}
onNoData {
// Called when state is Success without data
}
onError { error ->
// Called when state is Error (event-based)
}
onErrorState { error ->
// Called when state is Error (state-based)
}
// Configuration
loadingMessage("Loading...")
disableLoading()
forceLoading()
}@AndroidEntryPoint
class PhonePreviewsFragment : BaseStateFragment<MainFragmentPhonePreviewsBinding>() {
private val phoneViewModel by activityViewModels<PhoneViewModel>()
override fun onViewPrepared(view: View, savedInstanceState: Bundle?) {
subscribeViewModel()
}
private fun subscribeViewModel() {
// Use handleFlowWithViewLifecycle for Flow
handleFlowWithViewLifecycle(data = phoneViewModel.devicesState) {
onData { devices ->
// Handle success
}
}
}
}Note: This project doesn't enforce LiveData vs Flow. Choose based on your use case.
This project extends android-drawable-view to create drawables via XML attributes instead of drawable XML files.
Basic shapes using XML attributes:
<com.app.base.view.view.ShapeableView
android:layout_width="100dp"
android:layout_height="100dp"
app:shape_rectangle="true"
app:shape_corner_radius="10dp"
app:shape_solid_color="@color/primary" />Advanced shapes with gradient support:
<com.app.base.view.view.DView
android:layout_width="100dp"
android:layout_height="100dp"
app:d_shape_rectangle="true"
app:d_corner_radius="10dp"
app:d_gradient_start_color="@color/primary"
app:d_gradient_end_color="@color/primaryDark" />This approach eliminates the need for most drawable XML resources.
Simplified dialog API:
showConfirmDialog {
message = "Confirm action"
negativeText = "Cancel"
positiveText = "Confirm"
positiveListener = {
// Handle confirmation
}
cancelableTouchOutside = false
}Settings → Editor → General → Auto Import
- ✅ Check "Add unambiguous imports on the fly"
- ✅ Check "Optimize imports on the fly"
Settings → Editor → Code Style → Kotlin
- Set "Right margin" to your preference (recommended: 120-140)
- Configure wrapping rules for consistency
Settings → Editor → Code Style → XML
- Set line length and formatting rules
- Ensure consistent XML indentation
- Format code before committing
- Minimize visibility (private → internal → public):
- Keep module-private code within the module
- Only truly shared code goes to common modules
- Use the most restrictive visibility possible
- Images: Place in
drawable/(notmipmap/, except launcher icons) - Icons: Use
xxhdpifor icon resources - Module resources: Keep module-private resources in the module
- Common resources: Place shared resources in common modules
Each module has a unique resource prefix (configured in build.gradle.kts). All resources must start with that prefix.
Examples:
home_for home featureaccount_for account featurecommon_for common modules
This includes Styles, IDs, Strings, and all other resource types.
cd scripts
./feature_generator.exe feature_name="new_feature" target_path="../feature/new_feature/"- Create
feature/new_feature/api/for interfaces - Create
feature/new_feature/main/for implementation - Add includes to
settings.gradle.kts
- AGP: 8.3.2
- Kotlin: 1.9.24
- JDK: 11
- Version Catalog:
gradle/libs.versions.toml
Located in plugins/convention/src/main/kotlin/com/android/app/build/:
ModularizationApplicationPlugin: For application modulesModularizationLibraryPlugin: For library modulesModularizationAPIPlugin: For API modules (feature interfaces)CommonLibraryPlugin: For common library setupComposeFeaturePlugin: For Compose feature modulesFinalApplicationPlugin: For final app configuration
The project supports switching between local and remote modules via settings.project.json:
- Controlled by:
DependencySubstitutionPlugininsettings.gradle.kts - forceSubstitution = true: Forces use of local modules
- useLocal: true: Per-module control
- Purpose: Enables development with local base modules while using published artifacts for others
Android Gradle Plugin 8.0+ enables nonTransitiveRClass by default:
- A module referencing another module's resources must use fully qualified names
- Example:
R.string.home_titleinstead ofR.string.title
Android Gradle Plugin 8.0+ uses namespace in build.gradle.kts:
- No need to specify
packageinAndroidManifest.xml - The
namespacedefines the R file's package name
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Format your code
- Commit your changes (follow commit message conventions)
- Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Use clear, descriptive commit messages
- Start with a verb:
feat:,fix:,docs:,refactor:,test:,chore: - Example:
feat(account): add biometric login support
- Follow existing code patterns and architecture
- Maintain consistency with surrounding code
- Add comments for complex logic
- Update documentation as needed
- Coding Standards: Code style and conventions (Chinese)
- Glossary: Project terminology and definitions (English)
- sample/tradition: View-based implementation examples
- sample/compose: Compose-based implementation examples
Copyright 2024 Ztiany
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
This project demonstrates modern Android development practices and serves as a reference for building scalable Android applications. Special thanks to the Android developer community for the amazing tools and libraries that make this architecture possible.
Made with ❤️ by Ztiany


