My team was given a particularly laborious task: implement responsive images throughout a legacy Rails application. Back then, the app was sending disproportionally large image files over to web browsers regardless of client device and viewport characteristics. Users had to bear the burden of prolonged load times and an overall bad experience which contributed towards decreasing conversion rates.
To solve this problem, we provisioned an on-the-fly image server to perform image manipulations in real time and wired it up to a CDN layer. Our first task was to assess whether the concept would work on a small portion of the product, which was far away from the home page and thereby did not have as much traffic.
The first version
With all the basic infrastructure in place, we looked for gems to help us implement responsive images but none proved to be suitable for our use case. We then started developing our own solution to fulfill the following requirements:
One Image (AR model) maps to one responsive image (one-to-one).
Responsive images can have different formats: HTML, CSS and JSON.
Formats can be shared among internal and external apps for preloading, JS integration and other purposes.
If you are wondering, a responsive image consists of a set of images where each one applies to a different screen size or device-pixel-ratio.
After some work, a ResponsiveImage
class similar to the following was born:
class ResponsiveImage
class << self
attr_reader :versions
def inherited(subclass)
subclass.version ImageVersion.new(:original)
end
def version(*args)
new_version = ImageVersion.new(*args)
(@versions ||= {}).merge!(new_version.id => new_version)
end
end
version ImageVersion.new(:original)
def initiliaze(image)
@image = image
end
def url(version_id)
# Generates a URL corresponding to a version_id
end
# Methods to extract representations...
end
As you may have figured, this class aims to provide functionality for subclasses to define and gather image versions:
class FreebieMainImage < ResponsiveImage
version :sm, 173, 130, [nil, 599]
version :md, 241, 181, [600, 991]
# More versions...
end
Take line 2 for example: it declares an image of 173×130 pixels which is meant to be displayed on viewport widths between 0 and 599 pixels. Also, the inherited
hook automatically declares an :original
version on all subclasses. Among other utilities, each version is meant to generate a distinct URL to the image server requesting a crop corresponding to the declared dimensions:
# Returns a URL for the :sm version
responsive_image.url(:sm)
In the end, a ResponsiveImage
object is nothing but a proxy for obtaining different representations, which functionality gets delegated away to specialized objects:
class ResponsiveImage
# ...
def to_picture
PictureTag.new(self)
end
# ...
end
Finally, here is an example usage:
<%= FreebieMainImage.new(freebie.main_image).to_picture %>
This ERB code outputs a picture tag HTML containing display rules and URLs for all versions declared in FreebieMainImage
.
A touch of convenience
That implementation proved to be a great fit for our problem, but using it was not without friction. Among other issues, we needed to reuse the same responsive image over a single endpoint, and instantiating objects more than once did not feel right.
Using the controller for this purpose would have been a bad move since it would encourage bloating all others:
class FreebiesController
def show
freebie = Freebie.find(params[:id])
@freebie = FreebiePresenter.new(freebie)
@main_image = FreebieResponsiveMainImage.new(@freebie.main_image)
end
end
We needed an abstraction to make this recurring code easier to work with. After thinking for a while, we came to the conclusion that a factory to group responsive images by model seemed like an appropriate solution.
Here’s the sketch: given our Freebie
model has one main_image
, our collection automatically figures the right presenter for it (FreebieMainImage
) and delivers the result through a hash-like interface.
Let’s apply some programming by wishful thinking and picture how our imaginary code ought to be used:
# Reads "freebie" images and maps them to the right responsive images
collection = ResponsiveImageCollection.new(freebie)
# We can now use our final responsive image product
puts collection[:main_image].to_picture
Finally, here’s the implementation:
class ResponsiveImageCollection
def initialize(model)
@model = model
@images = {}
end
def [](image_id)
@images[image_id] ||= begin
image = @model.public_send(image_id)
klass = find_responsive_image_class(image_id)
klass.new(image)
end
end
private
def find_responsive_image_class(image_id)
"#{@model.class.model_name.name}#{image_id.to_s.camelize}".constantize
end
end
As you can see, we are using a Railsy convention to find the responsive class of an image: if the model name is Freebie
and the image name is main_image
, we then look for a class named FreebieMainImage
.
The following method at FreebiePresenter
makes the code even more accessible and avoids further pollution of controllers:
class FreebiePresenter
# ...
def images
@images ||= ResponsiveImageCollection.new(self)
end
# ...
end
Finally, here’s how to use it in the views:
<%= @freebie.images[:main_image].to_picture %>
Advantages of the first version
The launch gone smooth and the code was technically adequate, because:
Concerns were kept separate and we did not bloat the model with responsive configuration. That would be more akin to a classic Rails solution.
The
ResponsiveImage
class was not coupled to any particular Image.The
version
method proved to be a great and nifty macro.Although palliative,
ResponsiveImageCollection
fulfilled the needs of the first launch: it mapped resources one-to-one but it did not couple the core system in any way.
Good enough, ship it!
The second version
The time came around to implement the system in a wider range of pages, right after our successful POC got deployed. Additionally, adapting the core was necessary to take into account a newfound complexity:
- One
Image
maps to manyResponsiveImage
and vice-versa (many-to-many).
We did some planning and went on to work on these new changes.
Realizing the symptoms
During our first iteration, we repeatedly found the need to declare responsive subclasses:
class SaleIndexImage < ResponsiveImage
# Versions...
end
class SaleMainImage < ResponsiveImage
# Versions...
end
class SaleFooImage < ResponsiveImage
# Versions...
end
class SaleBarImage < ResponsiveImage
# Versions...
end
# And a lot more... really!
Then an obvious realization emerged: creating a class for each and every responsive image would get exhausting pretty quickly.
We did not anticipate the pain because at first there were just a few configurations to deal with. This is OK: we released the minimal amount of code to solve our problem, but the solution had to evolve to accommodate newfound requirements and eliminate rough edges.
After asking a few questions to ourselves, an interesting fact turned up: the role of our subclasses was to gather and centralize data.
Wait a minute, isn’t inheritance mostly about behavior?
That’s right: inheritance is best for specializing behavior where a “Y is a X” kind of relationship exists. Of course, our subclasses did not meet those requirements: instead they acted as configuration hubs supplying data over to inherited methods.
Never should the role of inheritance be just to share methods and implementation details. This kind of misuse usually points toward design improvements.
As a side effect, our approach generated complexities not apparent at first sight such as looking for artifacts by class name. This is OK when dealing with SOLID-oriented code, but it did not happen to be our case.
The solution
Our problem seemed more suited to a registry that provides configuration to a factory. Didn’t get it? So bear with me.
The first step to refactor our solution was to start moving versions from class-level to instance-level, though not removing the class method yet:
Great, everything worked just as before and it was possible to instantiate responsive images on-the-fly without the bureaucracy of subclasses:
freebie_main_image = ResponsiveImage.new(freebie.main_image,
ImageVersion.new(:sm, 173, 130, [nil, 599]),
ImageVersion.new(:md, 241, 181, [600, 991])
])
Since we started moving versions into an instance method, the next step was to change all call-sites from class method to instance method (we will not present this step here).
However, we were still using subclasses:
FreebieMainImage.new(freebie.main_image)
Eliminating these artifacts proved to be a bit more difficult. First of all, we created a registry to allow getting rid of subclass-centric configuration:
class ResponsiveConfig
def self.config
yield instance
end
include Singleton
def initialize
@config = { default: [] }
end
def add(id, versions)
@config[id] = versions.map { |args| ImageVersion.new(*args) }
end
def fetch(id)
@config.fetch(id)
end
end
Then we moved versions of all subclasses onto it. For instance, the following code can be placed on a Rails initializer:
ResponsiveConfig.config do |config|
config.add :freebie_main_image, [
[:sm, 173, 130, [nil, 599]],
[:md, 241, 181, [600, 991]]
]
config.add :freebie_hero_image, [
[:sm, 173, 130, [nil, 599]],
[:md, 241, 181, [600, 991]]
]
end
As you can see, the add method receives a configuration ID and an array of version information which gets mapped away to Struct objects.
Next, we changed ResponsiveImageCollection to search for config keys instead of subclasses:
Finally, we were able to definitely delete ResponsiveImage.version
and ResponsiveImage.inherited
methods. Our class got much simpler:
But we were not over yet.
One last coupling
Refactoring our solution was relatively easy, but there were still problems. What if we wanted to reuse the same responsive configuration twice? The short answer is we couldn’t, because our collection class was using a hardcoded naming scheme to look up configurations.
For instance, if we had a Project
model and wanted to declare versions for its main_image
, we would have to use a project_main_image
key:
ResponsiveConfig.config do |config|
config.add :project_main_image, [
# Versions here...
]
end
This was inflexible and did not scale up, not to mention the naming scheme was obscure and hard to remember.
What if we had a way to explicitly map images to their responsive counterparts? In that case, we would be able to specify and repeat any combination and quantity of elements.
Firstly, we created a CollectionConfig
class similar to ResponsiveConfig
:
class CollectionConfig
include Singleton
CollectionMapping = Struct.new(:id, :responsive_config_id, :image_name)
def self.config
yield instance
end
def initialize
@config = { default: [] }
end
def add(id, mappings)
@config[id] = mappings.map { |args| CollectionMapping.new(*args) }
end
def fetch(id)
@config.fetch(id)
end
end
Then we configured our collection mappings like so:
CollectionConfig.config do |config|
config.add :freebie, [
[:main_image, :freebie_main_image, :main_image],
[:hero_image, :freebie_hero_image, :hero_image]
]
end
And finally, we changed our collection class to interpret these mappings:
Awesome! In doing that we were finally able to reuse the main_image
config on a random_image
(or any other image for the matter):
CollectionConfig.config do |config|
config.add :freebie, [
[:random_image, :freebie_main_image, :random_image],
[:main_image, :freebie_main_image, :main_image],
[:hero_image, :freebie_hero_image, :hero_image]
]
end
Using the system was still very easy, not to mention the ERB call sites did not change a tiny bit:
<%= freebie.images[:main_image].to_picture %>
Of course, our collection class could have been made more flexible. For example, we could have gotten rid of the model coupling: why does a collection need to be coupled to a model anyway?
However, do we need to? Nope, it’s a feature! Other kinds of collections can easily be created, and the underlying system is flexible enough to allow for just that.
Wrap up
The code in here was considerably changed to fit into main theme of this blog post. Also, we did not go over the real complexity faced while implementing the solution.
Nevertheless, the key ideas here are:
If the amount of change you need to perform on a task is huge, tackle it in small chunks and start small.
Don’t try to guess the future: write just the amount of code your solution needs. Listen to the pain and allow your system evolve naturally.
Inheritance is not bad, but it’s also not suited for many problems.
Beware when subclasses are used with the sole purpose of sharing data. Be attentive to their best usages and keep asking questions to yourself.
Thanks for reading and have a great day.
Thanks to Luiz Rogerio.
We want to work with you. Check out our "What We Do" section!