How To Rewrite Your Top-Grossing App Without Going Bankrupt

By: Anass AIZurba

Lightricks
Lightricks Tech Blog
12 min readFeb 14, 2023

--

Lightricks entered the market in 2013 with the launch of the award-winning Facetune app, followed by Enlight in the same year. In 2014 Facetune for Android was created. The app became so successful that, in 2016, Lightricks decided to create an enhanced version with new segmentation-based features and better automated editing tools: Facetune2

New Apps, New Options…

The revised Facetune2 was the catalyst Lightricks needed to kickstart its journey to become a $1.8 Billion company. The photo editing application allows users to pick a photo and edit it by applying several features in succession, after which they can export their result.

At the time of Facetune2’s creation, Swift was still a very new language, with APIs changing frequently. The Metal framework had also just been released. We didn’t have a lot of experience working with it, being more accustomed to OpenGL. We opted to write the app with the more familiar Objective-C alongside OpenGL for rendering.

In terms of its architecture, Facetune2 was built using the MVVM paradigm, with one super-feature that encompassed all capabilities of the different features. Each feature had to describe its components and inner tools using a JSON file (using capabilities already implemented in the super-feature), and it had to supply a processor that rendered the necessary effects.

This worked well for the initial batch of features, since they were all similar in relation to UI, and they did their processing differently. Adding a feature was a very simple undertaking. You defined the components using a JSON file, then defined how your processor worked given an input image. The infrastructure parses the JSON files at runtime and generates the required behavior dynamically using the super-feature mentioned above.

Growing Pains — And a Move to Metal

As time went on, new challenges arose. We started to get requests to create all sorts of new UI elements, and we were asked to create new features that didn’t necessarily resemble the old ones. This was a problem, since each new addition required changing our super-feature, adding more capabilities to it. This bloated our JSON descriptive language, and created a (frankly scary) signal muxing and demuxing code that dealt with all the different behaviors in the app as if they were in one feature.

In addition to that, the dynamic nature of Objective-C and OpenGL, while being versatile for all sorts of cool tricks, opened the door for a lot of runtime errors that would have been detected in Compile time in other statically typed languages.

Fast forward to 2019, after a long summer in which we migrated most of Facetune2 code from OpenGL to Metal. A new app was to be added to the family: Facetune Video.

Swift 5.0 was also released in 2019, and it was at a point where most of the language features were finally stable and mature enough that you could just use it in production without looking back. Metal 2.0 was also released in the same year, with all the additional features it brought with it. It was the perfect time for us to use these new technologies in our new app.

This was an excellent opportunity to try new things. Instead of risking our top-grossing app, we had an app that looked and felt very similar to Facetune2, in which we can try all kinds of new ideas, and see what we could do better.

Lessons Learned, Redux Ready…

One of the important lessons we learned from our previous experience with mobile development is that state-corruption is a very messy situation to be in. You want to always have one source of truth for your application’s state, and preferably derive all your UI presentation from it. Otherwise, you stumble into undefined and unexpected behaviors regularly.

We started looking into the Redux paradigm, with its promise to keep App State unified and consistent. Facetune Video was to be written in Swift and Metal, using the Redux paradigm. We also flipped the way we thought about features, writing each feature independently and extracting the shared code between them to create building blocks that you could decide to use or not depending on your specific feature.

In 2020 we released Facetune Video, and continued to be pleased both with the way it scaled during the first year, and with regard to how fun it was to work with the new codebase.

In 2021, after the success we had with Facetune Video, we started to think about two things. How could we improve the experience of developing for Facetune 2, given that we had lots of new ideas in store for it? And how could we unify the two apps into one consolidated Facetune App?

In both cases, we knew that a new rewrite of the app was coming. The old codebase from 2016 was becoming hard to maintain (even with the several refactors it had in its lifetime) and we were excited to see some of the new things we tried in Facetune Video come to life in Facetune 2.

The major concern was obviously how much effort and risk we were willing to invest to get this project done. A small task force was formed to kickstart the project, and they started rewriting parts of the codebase using the new architecture, just to test the waters. I joined the project one month in, coming from the Facetune Video team.

Rebuilding Facetune2

Initial signs were promising. We started seeing that the (few) things we wrote using the new paradigms looked better code-wise. We added a few more developers and started rewriting everything on a new integration branch. We wrote an initial list of components that we knew the app had, and started clearing them one by one.

The first thing we did was to write a basic import code. We wrote code that handles a very basic notion of a project, some code that detects faces, and started mapping out the more “interesting” features. We took four or five features that we thought were the most versatile and covered the different UI and rendering problems we knew we would face when rewriting everything (toolbars, sliders, state management, async processing, asset importing, user interactions and so on.)

Meanwhile, the development of the real app in production didn’t stop. The remaining devs in the team kept pushing changes to it. The understanding was that once we finish the rewriting, we would need to make up for whatever changes were added in this period. Obviously, the changes in the main app were not that huge or major, and this seemed like a logical thing to assume.

Our task force had three months to give a timeline of the actual rewriting process. At two months in, we started to run into some problems:

  1. The codebase was huge. A lot of behaviors implemented in the app were not documented anywhere, we had a lot of “hidden” requirements. We had marketing, analytics and purchase experience teams working with the app and each had expectations of what the app did and what kind of events and deep links it supported. Sometimes the people who defined the requirements were not here anymore, and breaking things can have tremendous impact on our bottom line
  2. Our new code needed to be preforment. Our initial assumption was that since a lot of time had passed since the app was written, and mobile phones’ processors (especially iphones’) had gotten way faster, we could do without a lot of the optimizations and hacky complex code that we had in our original codebase. This turned out to be only partially true. Some of the most complex optimizations in the code were very crucial for the app to behave in a reasonable way, and we didn’t want to end up with a slower app after we finished our project.

Both of these problems posed a real threat to the project, and we had to decide how we are moving on given these circumstances.

A Period of Problem Solving

We decided to attack the first problem head on. We started mapping the entire codebase looking for hidden functionality. We created lists of source code files, events and deeplinks, assigning members of the team to go over them one by one and understand what we were dealing with. We asked them to mark anything they don’t understand fully for later inspection. We talked with people from various departments to try and understand anything we still didn’t understand, and finally added all the new stuff we discovered to our list of components that need to be rewritten.

When it came to the second issue, we took a shortcut. After serious consideration, we understood that the root cause of most of our problems that lead to this project lay with the app infrastructure, and how it was limiting us when it came to adding new functionality within the app. We also understood that rewriting all the existing features will be a very painful and risky operation that might nip the project in the bud.

We found out that since the old Facetune was built with one “super feature” that mimicked any feature on demand, we could take this feature as a black box and get it working in the new architecture. This idea was a game changer. We reduced the risk of the project by an order of magnitude, and were able to postpone the rewriting of each feature to whenever that feature needed to be edited to add support for new functionality. This was facilitated by the fact that the new architecture allowed each feature to behave independently.

In addition to the super feature, we also decided to not rewrite the Home screen of the app. Neither component would add its state to the Redux app state, but we compensated by adding code that reported the parts of the state of these objects back to the app when needed.

The Next Steps to Success

When the first three months finished, we had a semi-functional app with almost 4 features fully rewritten and working (in addition to the super feature). Our app’s performance in the rewritten features was not quite as good as in the original ones, but we knew that we didn’t have to solve this problem immediately, and we now, at the very least, have a prototype for these features that would help us when the time comes to optimize.

We held a meeting with upper management to present the results, explaining why we thought this rewriting was crucial, and in what ways rewriting the infrastructure of the app could benefit us in the long run. We presented a demo of all our progress up until that point in time, and explained our plan moving ahead. We estimated that given what we had already achieved, we could finish the project in four to six more months.

After we got approval from the management, we started adding code to the develop branch. Our approach was to create another application (new AppDelegate) in the same codebase and even in the same build target. We would have a flag in the main function of the app (in main.swift) that switches between the old and the new app. Both apps would obviously still share some code. The product team wanted to run an A/B test to make sure we won’t hurt our bottom line with the app replacement.

The following months were actually fun! We started by rethinking some of the decisions we already made during the POC stage, and some stuff was rearranged based on our evolving understanding. We had to solve new problems when migrating things we hadn’t touched yet. This time a lot of emphasis was put on system-wide services and the best way to make them work. Swift’s static-type nature helped us to write our infrastructure code in ways that made it hard for developers to abuse the new system (by using enums and optionals, when applicable, instead of flags for instance.)

Naturally, we ran into a whole lot of problems and weird behaviors as we were integrating the new app. We kept a backlog of all the issues we encountered, and categorized them into more pressing “blocking” issues and others that could be solved after we release the new app. We understood that some of the old code still needed to be in use, so we tried to wrap it using the APIs that we would’ve wanted to have once we rewrote this code later.

The End in Sight

As we approached the finish line, we started going through our backlog of issues. Some of the problems were already solved, but we had to invest some time into the rest. We also sent an early version of the new app to our QA agency to check the app for issues. Surprisingly, the list of bugs they sent back was relatively small. We added the final touches and sent the app for another cycle of QA testing (in addition to regular in-house testing by the developers throughout the development process).

Then the moment of truth came. After six months of working on the develop branch, we set up the A/B test and sent a version to the App Store. We started a phase release and started monitoring the Crashlytics board, expecting all kinds of crashes to start appearing. We ended up submitting three hotfixes, but somehow all of them were bugs in the way we set up the A/B test, and not in the actual rewritten app. Apparently setting up an experiment in main.swift is a tricky thing to do!

In the following month, we did discover some issues with the analytics reporting in the new app. This was due to some integration code that was only run in release, and wasn’t properly E2E tested prior to this. In addition, there were some minor bugs that we solved immediately as we discovered them

In this period, we also started to catch up with the stuff added in the old app, making sure everything was working properly in the new app. After a while, we pushed a new version to conclude the A/B test with all of our users getting the new app. It was still missing some capabilities, but we figured that they were not widely used and we intended to reintroduce them at a later stage. All in all, we felt very fortunate: things went much more smoothly than we expected and the new version went live to all users with relatively few problems.

Once the version was out, we started the clean up process. We now had a huge amount of dead code that needed to be deleted, and we still had some old code running in the app that needed to be replaced. It took us a few months to get rid of most of the dead code.

What We Learnt

The main thing this process taught us was to do things in steps, defining priorities and working accordingly. If we’d insisted on rewriting everything from the outset, we would have had to pay a significantly higher price for almost no additional value. Our most pressing issue was in the infrastructure of the app, and rewriting that part of the code represented the higher priority.

The whole process was challenging at times, but also lots of fun. We learned so much and grew a lot — as a team and individuals. We came to appreciate the power of expressive APIs and good state modeling that directs developers to use the app infrastructure correctly.

Other teams in the company heard about the project we were working on, and we were approached by people from other teams wishing to introduce to their apps some of the new patterns and design principles we used in our app.

In the months following the rewriting, Facetune Video was integrated fully into Facetune, and the app had a UI refresh to make both editors uniform. In terms of team structure, the Facetune dev force doubled its size, and was split to four teams working on a single repo, instead of two teams working on two repos. The new infrastructure facilitated development velocity and independence between teams that wasn’t possible before, without compromising on app stability.


Create magic with us
We’re always on the lookout for promising new talent. If you’re excited about developing groundbreaking new tools for creators, we want to hear from you. From writing code to researching new features, you’ll be surrounded by a supportive team who lives and breathes technology.
Sounds like you? Apply here.

--

--

Lightricks
Lightricks Tech Blog

Learn more about how Lightricks is pushing the limits of technology to bridge the gap between imagination and creation.