Object-Oriented Programming Best Practices with Kotlin

Learn how to write better code following Object Oriented Programming Best Practices with Kotlin and SOLID principles by developing a Terminal Android app. By Ivan Kušt.

3 (2) · 1 Review

Download materials
Save for later
Share

Object-Oriented Programming (OOP) is the most popular computer programming paradigm. Using it properly can make your life, and your coworkers’, lives easier. In this tutorial, you’ll build a terminal app to execute shell commands on Android.

In the process, you’ll learn the following:

  • Key principles of Object-Oriented Programming.
  • SOLID principles and how they make your code better.
  • Some Kotlin specific good-to-knows.

Also, if you’re completely new to Android development, read through our Beginning Android Development tutorials to familiarize yourself with the basics.

Note: This tutorial assumes you know the basics of Android development with Kotlin. However, if you’re new to Kotlin, check out our Kotlin introduction tutorial.

Also, if you’re completely new to Android development, read through our Beginning Android Development tutorials to familiarize yourself with the basics.

Getting started

To begin with, download the Kodeco Shell project using the Download Materials button at the top or bottom of this tutorial.

Open the starter project in Android Studio 2022.2.1 or later by selecting Open on the Android Studio welcome screen:

Android Studio Welcome Screen

The app consists of a single screen similar to Terminal on Windows/Linux/MacOS. It lets you input commands and show their output and errors. Additionally, there are two actions, one to stop a running command and one to clear the output.

Build and run the project. You should see the main, and only, screen of the app:

Main Screen

Whoa, what’s going on here? As you can see, the app currently refuses to run any commands, it just displays a non-cooperative message. Therefore, your job will be to use OOP Best Practices and fix that! You’ll add the ability to input commands and display their output.

Understanding Object-Oriented Programming?

Before adding any code, you should understand what OOP is.

Object-Oriented Programming is a programming model based on data. Everything is modeled as objects that can perform certain actions and communicate with each other.

For example, if you were to represent a car in object-oriented programming, one of the objects would be a Car. It would contain actions such as:

  • Accelerate
  • Brake
  • Steer left
  • Steer right

Classes and Objects

One of the most important distinctions in object-oriented programming is between classes and objects.

Continuing the car analogy, a class would be a concrete car model and make you can buy, for example — Fiat Panda.

A class describes how the car behaves, such as its top speed, how fast it can accelerate, etc. It is like a blueprint for the car.

An object is an instance of a car, if you go to a dealership and get yourself a Fiat Panda, the Panda you’re now driving in is an object.

Class vs Object

Let’s take a look at classes in KodecoShell app:

  • MainActivity class represents the screen shown when you open the app.
  • TerminalCommandProcessor class processes commands that you’ll enter on the screen and takes care of capturing their output and errors.
  • Shell class executes the commands using Android runtime.
  • TerminalItem class represents a chunk of text shown on the screen, a command that was entered, its output or error.

KodecoShell classes

MainActivity uses TerminalCommandProcessor to process the commands the user enters. To do so, it first needs to create an object from it, referred to as “creating an object” or “instantiating an object of a class”.

To achieve this in Kotlin, you use:

private val commandProcessor: TerminalCommandProcessor = TerminalCommandProcessor()

Afterward, you could use it by calling its functions, for example:

commandProcessor.init()

Key Principles of OOP

Now that you know the basics, it’s time to move on to the key principles of OOP:

  • Encapsulation
  • Abstraction
  • Inheritance
  • Polymorphism

These principles make it possible to build code that is easy to understand and maintain.

Understanding Encapsulation and Kotlin Classes

Data inside a class can be restricted. Make sure other classes can only change the data in expected ways and prevent state inconsistencies.

In short, the outside world doesn’t need to know how a class does something, but what it does.

In Kotlin, you use visibility modifiers to control the visibility of properties and functions inside classes. Two of the most important ones are:

  • private: property or function is only visible inside the class where it’s defined.
  • public: default visibility modifier if none is specified, property or function is visible everywhere.
Note: For more information on Kotlin visibility modifiers check the official documentation.

Marking the internal data of a class as private prevents other classes from modifying it unexpectedly and causing errors.

To see this in action, open TerminalCommandProcessor class and add the following import:

import com.kodeco.android.kodecoshell.processor.shell.Shell

Then, add the following inside the class:

private val shell = Shell(
    outputCallback = { outputCallback(TerminalItem(it)) },
    errorCallback = { outputCallback(TerminalItem(it)) }
)

You instantiated a Shell to run shell commands. You can’t access it outside of TerminalCommandProcessor. You want other classes to use process() to process commands via TerminalCommandProcessor.

Note you passed blocks of code for outputCallback and errorCallback parameters. Shell will execute one of them when its process function is called.

To test this, open MainActivity and add the following line at the end of the onCreate function:

commandProcessor.shell.process("ps")

This code tries to use the shell property you’ve just added to TerminalCommandProcessor to run the ps command.

However, Android Studio will show the following error:
Cannot access 'shell': it is private in 'TerminalCommandProcessor'

Delete the line and return to TerminalCommandProcessor. Now change the init() function to the following:

fun init() {
  shell.process("ps")
}

This code executes when the application starts because MainActivity calls TerminalViews‘s LaunchEffect.

Note: For more information on Jetpack Compose and launch effects check the official documentation.

Build and run the app.

As a result, now you should see the output of the ps command, which is the list of the currently running processes.

PS output

Abstraction

This is similar to encapsulation, it allows access to classes through a specific contract. In Kotlin, you can define that contract using interfaces.

Interfaces in Kotlin can contain declarations of functions and properties. But, the main difference between interfaces and classes is that interfaces can’t store state.

In Kotlin, functions in interfaces can have implementations or be abstract. Properties can only be abstract; otherwise, interfaces could store state.

Note: For more information about interfaces in Kotlin check the official documentation.

Open TerminalCommandProcessor and replace class keyword with interface.

Note Android Studio’s error for the shell property: Property initializers aren't allowed in interfaces.

As mentioned, interfaces can’t store state, and you cannot initialize properties.

Delete the shell property to eliminate the error.

You’ll get the same error for the outputCallback property. In this case, remove only the initializer:

var outputCallback: (TerminalItem) -> Unit

Now you have an interface with three functions with implementations.

Replace init function with the following:

fun init()

This is now an abstract function with no implementation. All classes that implement TerminalCommandProcessor interface must provide the implementation of this function.

Replace process and stopCurrentCommand functions with the following:

fun process(command: String)

fun stopCurrentCommand()

Classes in Kotlin can implement one or more interfaces. Each interface a class implements must provide implementations of all its abstract functions and properties.

Create a new class ShellCommandProcessor implementing TerminalCommandProcessor in processor/shell package with the following content:

package com.kodeco.android.kodecoshell.processor.shell

import com.kodeco.android.kodecoshell.processor.TerminalCommandProcessor
import com.kodeco.android.kodecoshell.processor.model.TerminalItem

class ShellCommandProcessor: TerminalCommandProcessor { // 1
  // 2  
  override var outputCallback: (TerminalItem) -> Unit = {}

  // 3
  private val shell = Shell(
    outputCallback = { outputCallback(TerminalItem(it)) },
    errorCallback = { outputCallback(TerminalItem(it)) }
  )

  // 4
  override fun init() {
    outputCallback(TerminalItem("Welcome to Kodeco shell - enter your command ..."))
  }

  override fun process(command: String) {
    shell.process(command)
  }

  override fun stopCurrentCommand() {   
    shell.stopCurrentCommand()
  }
}

Let’s go over this step-by-step.

  1. You implement TerminalCommandProcessor interface.
  2. You declare a property named outputCallback and use the override keyword to declare that it’s an implementation of property with the same name from TerminalCommandProcessor interface.
  3. You create a private property holding a Shell object for executing commands. You pass the code blocks that pass the command output and errors to outputCallback wrapped in TerminalItem objects.
  4. Implementations of init, process and stopCurrentCommand functions call appropriate Shell object functions.

You need one more MainActivity change to test the new code. So, add the following import:

import com.kodeco.android.kodecoshell.processor.shell.ShellCommandProcessor

Then, replace commandProcessor property with:

private val commandProcessor: TerminalCommandProcessor = ShellCommandProcessor()

Build and run the app.

Welcome to Kodeco Shell